From d3004e1b442d184f26cd1677da67f99767ac7e77 Mon Sep 17 00:00:00 2001 From: Mateusz Charytoniuk Date: Tue, 5 May 2026 03:30:14 +0200 Subject: [PATCH 01/51] Surface token usage and classify reasoning/tool-call/content deltas Wire format ----------- - Replace GeneratedTokenResult::Token(String) with three explicit variants: ContentToken, ReasoningToken, ToolCallToken (plus existing UndeterminableToken). - Replace GeneratedTokenResult::Done unit variant with Done(GenerationSummary) carrying the final TokenUsage with prompt/cached/image/audio/content/ reasoning/tool_call/undeterminable token counts. Agent ----- - Construct a per-request SampledTokenClassifier from the model and feed every sampled token through ingest(), then emit the matching token variant on the inference channel; usage is converted from the bindings type once per generation and shipped on the Done event. Transformer trait ----------------- - TransformsOutgoingMessage::transform now returns Vec so a single message can produce multiple SSE chunks. OpenAI compat endpoint ---------------------- - Add stream_options.include_usage; honor it to emit a final usage chunk in streaming mode. - Route reasoning tokens to delta.reasoning_content and tool-call tokens into delta.tool_calls function arguments fragments per the streaming spec. - Forward the tools array from the request through to the agent. - Non-streaming path replaces the simple text concatenator with an Arc-backed aggregator that buffers content/reasoning/tool-call text separately, parses tool-call JSON for name/arguments, and emits a single OpenAI chat.completion JSON with finish_reason "tool_calls" when applicable. Local bindings -------------- - Workspace dependency now points at sibling llama-cpp-bindings checkout (mtmd became always-on so the feature flag is dropped). --- Cargo.lock | 12 +- Cargo.toml | 4 +- .../agent/continuous_batch_active_request.rs | 6 +- paddler/src/agent/continuous_batch_arbiter.rs | 7 +- .../src/agent/continuous_batch_scheduler.rs | 125 ++- paddler/src/agent/mod.rs | 1 + .../src/agent/token_usage_from_bindings.rs | 16 + .../identity_transformer.rs | 4 +- .../mod.rs | 13 +- .../transforms_outgoing_message.rs | 2 +- .../http_route/post_chat_completions.rs | 986 +++++++++++++----- .../api/post_generate_embedding_batch.rs | 6 +- paddler_tests/src/collect_generated_tokens.rs | 2 +- paddler_tests/src/lib.rs | 1 + .../src/openai_chat_completions_client.rs | 86 ++ ...t_conversation_accepts_empty_tools_list.rs | 3 +- ...onversation_history_respects_max_tokens.rs | 3 +- .../agent_raw_prompt_respects_max_tokens.rs | 3 +- ...our_concurrent_clients_streaming_tokens.rs | 3 +- ...ens_from_conversation_history_over_http.rs | 3 +- ...gent_streams_tokens_from_image_data_uri.rs | 3 +- .../agent_streams_tokens_from_raw_prompt.rs | 3 +- ..._drains_in_flight_inference_before_swap.rs | 4 +- ...emplate_override_replaces_model_builtin.rs | 3 +- ..._template_swaps_between_inference_calls.rs | 3 +- ..._conversation_history_requests_complete.rs | 8 +- ..._evicts_long_sequence_under_kv_pressure.rs | 2 +- ...kens_with_distinct_k_and_v_cache_dtypes.rs | 4 +- ...rates_tokens_with_partial_layer_offload.rs | 4 +- ...and_short_prompts_complete_concurrently.rs | 8 +- ...h_plain_and_multimodal_run_concurrently.rs | 4 +- ...tch_reuses_slot_after_request_completes.rs | 6 +- ...s_batch_serves_four_concurrent_requests.rs | 4 +- paddler_tests/tests/continuous_batch_smoke.rs | 4 +- ...uous_batch_stops_at_max_tokens_boundary.rs | 4 +- ...ops_generation_when_stop_sender_dropped.rs | 4 +- ...rent_multimodal_requests_produce_tokens.rs | 4 +- ...n25vl_generates_tokens_from_image_input.rs | 4 +- ..._tokens_for_long_system_and_user_prompt.rs | 4 +- ...neration_stops_at_eog_before_max_tokens.rs | 4 +- ...ng_mode_stops_cleanly_before_max_tokens.rs | 4 +- ...g_multi_turn_conversation_stops_cleanly.rs | 4 +- ...with_mmproj_generates_tokens_from_image.rs | 4 +- ..._system_message_completes_with_thinking.rs | 4 +- ...stem_message_completes_without_thinking.rs | 4 +- ...erates_tokens_from_conversation_history.rs | 4 +- .../qwen3_generates_tokens_from_raw_prompt.rs | 4 +- ...nternal_endpoint_emits_tool_call_tokens.rs | 90 ++ ...king_disabled_emits_no_reasoning_tokens.rs | 70 ++ ...thinking_enabled_emits_reasoning_tokens.rs | 71 ++ ...ming_emits_tool_calls_for_function_tool.rs | 85 ++ ...wen3_openai_non_streaming_returns_usage.rs | 58 ++ ...ming_emits_tool_calls_for_function_tool.rs | 64 ++ ...ai_streaming_emits_usage_when_requested.rs | 67 ++ ...treaming_omits_usage_when_not_requested.rs | 42 + ...g_routes_reasoning_to_reasoning_content.rs | 59 ++ ..._grammar_generates_unconstrained_output.rs | 3 +- ...lvlm2_generates_tokens_from_image_input.rs | 4 +- paddler_types/src/generated_token_result.rs | 50 +- paddler_types/src/generation_summary.rs | 10 + paddler_types/src/lib.rs | 2 + paddler_types/src/token_usage.rs | 30 + 62 files changed, 1736 insertions(+), 367 deletions(-) create mode 100644 paddler/src/agent/token_usage_from_bindings.rs create mode 100644 paddler_tests/src/openai_chat_completions_client.rs create mode 100644 paddler_tests/tests/qwen3_internal_endpoint_emits_tool_call_tokens.rs create mode 100644 paddler_tests/tests/qwen3_internal_endpoint_with_thinking_disabled_emits_no_reasoning_tokens.rs create mode 100644 paddler_tests/tests/qwen3_internal_endpoint_with_thinking_enabled_emits_reasoning_tokens.rs create mode 100644 paddler_tests/tests/qwen3_openai_non_streaming_emits_tool_calls_for_function_tool.rs create mode 100644 paddler_tests/tests/qwen3_openai_non_streaming_returns_usage.rs create mode 100644 paddler_tests/tests/qwen3_openai_streaming_emits_tool_calls_for_function_tool.rs create mode 100644 paddler_tests/tests/qwen3_openai_streaming_emits_usage_when_requested.rs create mode 100644 paddler_tests/tests/qwen3_openai_streaming_omits_usage_when_not_requested.rs create mode 100644 paddler_tests/tests/qwen3_openai_streaming_routes_reasoning_to_reasoning_content.rs create mode 100644 paddler_types/src/generation_summary.rs create mode 100644 paddler_types/src/token_usage.rs diff --git a/Cargo.lock b/Cargo.lock index ef76369c..4ef464fe 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3528,9 +3528,7 @@ checksum = "11d3d7f243d5c5a8b9bb5d6dd2b1602c0cb0b9db1621bafc7ed66e35ff9fe092" [[package]] name = "llama-cpp-bindings" -version = "0.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9627055cad4854d59fcc449c9ddf3f93f96951cb1e120e6dc4c9a2af51902862" +version = "0.5.0" dependencies = [ "encoding_rs", "enumflags2", @@ -3542,9 +3540,7 @@ dependencies = [ [[package]] name = "llama-cpp-bindings-build" -version = "0.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6d19130eaca34f6578ee105766f08fd004f1d2c1e187cb75cf51a444852dd4aa" +version = "0.5.0" dependencies = [ "bindgen", "cc", @@ -3556,9 +3552,7 @@ dependencies = [ [[package]] name = "llama-cpp-bindings-sys" -version = "0.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "15bf30008f4d6624d200b3fc5ea953e7c08410b6d14dabecc6e4b288f20f9f05" +version = "0.5.0" dependencies = [ "llama-cpp-bindings-build", ] diff --git a/Cargo.toml b/Cargo.toml index 706fe91e..45fcf126 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -35,8 +35,8 @@ hf-hub = { version = "0.4", features = ["tokio"] } image = "0.25" indoc = "2" jsonschema = { version = "0.37", default-features = false } -llama-cpp-bindings = { version = "0.4.2", features = ["mtmd"] } -llama-cpp-bindings-sys = "0.4.2" +llama-cpp-bindings = { path = "../llama-cpp-bindings/llama-cpp-bindings" } +llama-cpp-bindings-sys = { path = "../llama-cpp-bindings/llama-cpp-bindings-sys" } base64 = "0.22" log = "0.4" mime_guess = "2" diff --git a/paddler/src/agent/continuous_batch_active_request.rs b/paddler/src/agent/continuous_batch_active_request.rs index 1eb0413f..d3523ce1 100644 --- a/paddler/src/agent/continuous_batch_active_request.rs +++ b/paddler/src/agent/continuous_batch_active_request.rs @@ -1,3 +1,5 @@ +use llama_cpp_bindings::SampledToken; +use llama_cpp_bindings::SampledTokenClassifier; use llama_cpp_bindings::sampling::LlamaSampler; use llama_cpp_bindings::token::LlamaToken; use log::warn; @@ -9,14 +11,14 @@ use crate::agent::continuous_batch_request_phase::ContinuousBatchRequestPhase; pub struct ContinuousBatchActiveRequest { pub chain: LlamaSampler, + pub token_classifier: SampledTokenClassifier, pub current_token_position: i32, pub grammar_sampler: Option, - pub generated_tokens_count: i32, pub generated_tokens_tx: mpsc::UnboundedSender, pub generate_tokens_stop_rx: mpsc::UnboundedReceiver<()>, pub i_batch: Option, pub max_tokens: i32, - pub pending_sampled_token: Option, + pub pending_sampled_token: Option, pub phase: ContinuousBatchRequestPhase, pub prompt_tokens: Vec, pub prompt_tokens_ingested: usize, diff --git a/paddler/src/agent/continuous_batch_arbiter.rs b/paddler/src/agent/continuous_batch_arbiter.rs index 384c542b..618cab06 100644 --- a/paddler/src/agent/continuous_batch_arbiter.rs +++ b/paddler/src/agent/continuous_batch_arbiter.rs @@ -8,6 +8,7 @@ use std::thread::available_parallelism; use anyhow::Context as _; use anyhow::Result; use anyhow::anyhow; +use llama_cpp_bindings::SampledToken; use llama_cpp_bindings::context::params::LlamaContextParams; use llama_cpp_bindings::llama_backend::LlamaBackend; use llama_cpp_bindings::model::LlamaModel; @@ -255,19 +256,19 @@ impl ContinuousBatchArbiter { model_path: model_path.clone(), multimodal_context, token_bos_str: model.token_to_piece( - model.token_bos(), + &SampledToken::Content(model.token_bos()), &mut special_token_decoder, true, None, )?, token_nl_str: model.token_to_piece( - model.token_nl(), + &SampledToken::Content(model.token_nl()), &mut special_token_decoder, true, None, )?, token_eos_str: model.token_to_piece( - model.token_eos(), + &SampledToken::Content(model.token_eos()), &mut special_token_decoder, true, None, diff --git a/paddler/src/agent/continuous_batch_scheduler.rs b/paddler/src/agent/continuous_batch_scheduler.rs index 50e29b7f..5e097e69 100644 --- a/paddler/src/agent/continuous_batch_scheduler.rs +++ b/paddler/src/agent/continuous_batch_scheduler.rs @@ -7,6 +7,7 @@ use std::time::Duration; use anyhow::Result; use anyhow::anyhow; use llama_cpp_bindings::DecodeError; +use llama_cpp_bindings::SampledToken; use llama_cpp_bindings::context::LlamaContext; use llama_cpp_bindings::llama_batch::LlamaBatch; use llama_cpp_bindings::model::AddBos; @@ -20,6 +21,7 @@ use log::info; use log::warn; use paddler_types::embedding_result::EmbeddingResult; use paddler_types::generated_token_result::GeneratedTokenResult; +use paddler_types::generation_summary::GenerationSummary; use paddler_types::request_params::ContinueFromRawPromptParams; use rand::Rng as _; use rand::rngs::ThreadRng; @@ -40,6 +42,7 @@ use crate::agent::resolve_grammar::resolve_grammar; use crate::agent::sample_token_at_batch_index::sample_token_at_batch_index; use crate::agent::sampling_outcome::SamplingOutcome; use crate::agent::sequence_id_pool::SequenceIdPool; +use crate::agent::token_usage_from_bindings::token_usage_from_bindings; use crate::decoded_image::DecodedImage; use crate::dispenses_slots::DispensesSlots; use crate::slot_aggregated_status::SlotAggregatedStatus; @@ -399,6 +402,33 @@ impl ContinuousBatchScheduler { let chain = self.create_sampler_chain(); + let mut token_classifier = match self.scheduler_context.model.sampled_token_classifier() { + Ok(token_classifier) => token_classifier, + Err(err) => { + let message = format!( + "{:?}: failed to build reasoning token classifier: {err}", + self.scheduler_context.agent_name + ); + + error!("{message}"); + self.sequence_id_pool.release(sequence_id); + + if generated_tokens_tx + .send(GeneratedTokenResult::SamplerError(message)) + .is_err() + { + warn!( + "{:?}: failed to send result to client (receiver dropped)", + self.scheduler_context.agent_name + ); + } + + return; + } + }; + + token_classifier.record_prompt_tokens(prompt_tokens.len() as u64); + #[expect( clippy::cast_sign_loss, reason = "sequence IDs are always non-negative" @@ -423,9 +453,9 @@ impl ContinuousBatchScheduler { self.active_requests.push(ContinuousBatchActiveRequest { chain, + token_classifier, current_token_position: 0, grammar_sampler: llama_grammar_sampler, - generated_tokens_count: 0, generated_tokens_tx, generate_tokens_stop_rx, i_batch: None, @@ -560,13 +590,39 @@ impl ContinuousBatchScheduler { self.harvest_pending_samples_before_external_decode(); + let mut token_classifier = match self.scheduler_context.model.sampled_token_classifier() { + Ok(token_classifier) => token_classifier, + Err(err) => { + let message = format!( + "{:?}: failed to build reasoning token classifier: {err}", + self.scheduler_context.agent_name + ); + + error!("{message}"); + self.sequence_id_pool.release(sequence_id); + + if generated_tokens_tx + .send(GeneratedTokenResult::SamplerError(message)) + .is_err() + { + warn!( + "{:?}: failed to send result to client (receiver dropped)", + self.scheduler_context.agent_name + ); + } + + return; + } + }; + #[expect( clippy::cast_possible_truncation, clippy::cast_possible_wrap, reason = "batch_size fits in i32 for llama.cpp FFI" )] - let tokens_ingested = match input_chunks - .eval_chunks( + let tokens_ingested = match token_classifier + .eval_multimodal_chunks( + &input_chunks, multimodal_context, &self.llama_context, 0, @@ -621,9 +677,9 @@ impl ContinuousBatchScheduler { self.active_requests.push(ContinuousBatchActiveRequest { chain, + token_classifier, current_token_position: tokens_ingested, grammar_sampler: llama_grammar_sampler, - generated_tokens_count: 0, generated_tokens_tx, generate_tokens_stop_rx, i_batch: Some(-1), @@ -660,8 +716,9 @@ impl ContinuousBatchScheduler { &mut active_request.chain, &mut active_request.grammar_sampler, ) { - Ok(SamplingOutcome::Token(sampled_token)) => { - active_request.pending_sampled_token = Some(sampled_token); + Ok(SamplingOutcome::Token(raw_token)) => { + active_request.pending_sampled_token = + Some(active_request.token_classifier.ingest(raw_token)); active_request.i_batch = None; } Ok(SamplingOutcome::AllCandidatesEliminated) => { @@ -703,9 +760,13 @@ impl ContinuousBatchScheduler { fn check_stop_signals(&mut self) { for active_request in &mut self.active_requests { if active_request.is_stop_requested() { + let summary = GenerationSummary { + usage: token_usage_from_bindings(active_request.token_classifier.usage()), + }; + active_request.complete_with_outcome( &self.scheduler_context.agent_name, - GeneratedTokenResult::Done, + GeneratedTokenResult::Done(summary), ); } } @@ -839,13 +900,13 @@ impl ContinuousBatchScheduler { continue; }; - let sampled_token = match sample_token_at_batch_index( + let raw_token = match sample_token_at_batch_index( &self.llama_context, batch_index, &mut active_request.chain, &mut active_request.grammar_sampler, ) { - Ok(SamplingOutcome::Token(sampled_token)) => sampled_token, + Ok(SamplingOutcome::Token(raw_token)) => raw_token, Ok(SamplingOutcome::AllCandidatesEliminated) => { error!( "{:?}: sequence {} sampling exhausted candidates", @@ -883,16 +944,22 @@ impl ContinuousBatchScheduler { } }; - if self.scheduler_context.model.is_eog_token(sampled_token) { + let sampled_token = active_request.token_classifier.ingest(raw_token); + + if self.scheduler_context.model.is_eog_token(&sampled_token) { + let summary = GenerationSummary { + usage: token_usage_from_bindings(active_request.token_classifier.usage()), + }; + active_request.complete_with_outcome( &self.scheduler_context.agent_name, - GeneratedTokenResult::Done, + GeneratedTokenResult::Done(summary), ); continue; } let output_string = match self.scheduler_context.model.token_to_piece( - sampled_token, + &sampled_token, &mut active_request.utf8_decoder, true, None, @@ -913,9 +980,18 @@ impl ContinuousBatchScheduler { } }; + let token_event = match sampled_token { + SampledToken::Content(_) => GeneratedTokenResult::ContentToken(output_string), + SampledToken::Reasoning(_) => GeneratedTokenResult::ReasoningToken(output_string), + SampledToken::ToolCall(_) => GeneratedTokenResult::ToolCallToken(output_string), + SampledToken::Undeterminable(_) => { + GeneratedTokenResult::UndeterminableToken(output_string) + } + }; + if active_request .generated_tokens_tx - .send(GeneratedTokenResult::Token(output_string)) + .send(token_event) .is_err() { warn!( @@ -929,12 +1005,19 @@ impl ContinuousBatchScheduler { continue; } - active_request.generated_tokens_count += 1; + let usage = active_request.token_classifier.usage(); + let completion_so_far = usage.content_tokens() + + usage.reasoning_tokens() + + usage.undeterminable_tokens(); + + if completion_so_far >= active_request.max_tokens as u64 { + let summary = GenerationSummary { + usage: token_usage_from_bindings(active_request.token_classifier.usage()), + }; - if active_request.generated_tokens_count >= active_request.max_tokens { active_request.complete_with_outcome( &self.scheduler_context.agent_name, - GeneratedTokenResult::Done, + GeneratedTokenResult::Done(summary), ); continue; } @@ -970,7 +1053,7 @@ impl ContinuousBatchScheduler { let batch_position = batch.n_tokens(); batch.add( - pending_token, + &pending_token, active_request.current_token_position, &[active_request.sequence_id], true, @@ -1022,7 +1105,7 @@ impl ContinuousBatchScheduler { let is_last_token_of_prompt = is_last_chunk && offset == chunk_size - 1; batch.add( - *token, + &SampledToken::Content(*token), position, &[active_request.sequence_id], is_last_token_of_prompt, @@ -1153,11 +1236,13 @@ impl ContinuousBatchScheduler { self.sequence_id_pool.release(removed_request.sequence_id); self.slot_aggregated_status.release_slot(); + let usage = removed_request.token_classifier.usage(); + debug!( - "{:?}: cleaned up sequence {} ({} tokens generated)", + "{:?}: cleaned up sequence {} ({} completion tokens generated)", self.scheduler_context.agent_name, removed_request.sequence_id, - removed_request.generated_tokens_count, + usage.content_tokens() + usage.reasoning_tokens() + usage.undeterminable_tokens(), ); } } diff --git a/paddler/src/agent/mod.rs b/paddler/src/agent/mod.rs index 0f5a7789..b12c9f85 100644 --- a/paddler/src/agent/mod.rs +++ b/paddler/src/agent/mod.rs @@ -28,3 +28,4 @@ pub mod resolved_grammar; pub mod sample_token_at_batch_index; pub mod sampling_outcome; pub mod sequence_id_pool; +pub mod token_usage_from_bindings; diff --git a/paddler/src/agent/token_usage_from_bindings.rs b/paddler/src/agent/token_usage_from_bindings.rs new file mode 100644 index 00000000..1cfee476 --- /dev/null +++ b/paddler/src/agent/token_usage_from_bindings.rs @@ -0,0 +1,16 @@ +use llama_cpp_bindings::TokenUsage as BindingsTokenUsage; +use paddler_types::token_usage::TokenUsage; + +#[must_use] +pub fn token_usage_from_bindings(usage: &BindingsTokenUsage) -> TokenUsage { + TokenUsage { + prompt_tokens: usage.prompt_tokens(), + cached_prompt_tokens: usage.cached_prompt_tokens(), + input_image_tokens: usage.input_image_tokens(), + input_audio_tokens: usage.input_audio_tokens(), + content_tokens: usage.content_tokens(), + reasoning_tokens: usage.reasoning_tokens(), + tool_call_tokens: usage.tool_call_tokens(), + undeterminable_tokens: usage.undeterminable_tokens(), + } +} diff --git a/paddler/src/balancer/chunk_forwarding_session_controller/identity_transformer.rs b/paddler/src/balancer/chunk_forwarding_session_controller/identity_transformer.rs index f69872c3..8731595b 100644 --- a/paddler/src/balancer/chunk_forwarding_session_controller/identity_transformer.rs +++ b/paddler/src/balancer/chunk_forwarding_session_controller/identity_transformer.rs @@ -16,9 +16,9 @@ impl IdentityTransformer { #[async_trait] impl TransformsOutgoingMessage for IdentityTransformer { - async fn transform(&self, message: OutgoingMessage) -> Result { + async fn transform(&self, message: OutgoingMessage) -> Result> { let serialized = serde_json::to_string(&message)?; - Ok(TransformResult::Chunk(serialized)) + Ok(vec![TransformResult::Chunk(serialized)]) } } diff --git a/paddler/src/balancer/chunk_forwarding_session_controller/mod.rs b/paddler/src/balancer/chunk_forwarding_session_controller/mod.rs index 02dfd12f..3b147333 100644 --- a/paddler/src/balancer/chunk_forwarding_session_controller/mod.rs +++ b/paddler/src/balancer/chunk_forwarding_session_controller/mod.rs @@ -41,12 +41,15 @@ where TTransformsOutgoingMessage: Clone + TransformsOutgoingMessage + Send + Sync, { async fn send_response(&mut self, message: OutgoingMessage) -> anyhow::Result<()> { - match self.transformer.transform(message).await? { - TransformResult::Discard => Ok(()), - forwarded @ (TransformResult::Chunk(_) | TransformResult::Error(_)) => { - self.chunk_tx.send(forwarded)?; - Ok(()) + for transform_result in self.transformer.transform(message).await? { + match transform_result { + TransformResult::Discard => {} + forwarded @ (TransformResult::Chunk(_) | TransformResult::Error(_)) => { + self.chunk_tx.send(forwarded)?; + } } } + + Ok(()) } } diff --git a/paddler/src/balancer/chunk_forwarding_session_controller/transforms_outgoing_message.rs b/paddler/src/balancer/chunk_forwarding_session_controller/transforms_outgoing_message.rs index 2d3e27b4..ccfc438e 100644 --- a/paddler/src/balancer/chunk_forwarding_session_controller/transforms_outgoing_message.rs +++ b/paddler/src/balancer/chunk_forwarding_session_controller/transforms_outgoing_message.rs @@ -6,5 +6,5 @@ use super::transform_result::TransformResult; #[async_trait] pub trait TransformsOutgoingMessage { - async fn transform(&self, message: OutgoingMessage) -> Result; + async fn transform(&self, message: OutgoingMessage) -> Result>; } diff --git a/paddler/src/balancer/compatibility/openai_service/http_route/post_chat_completions.rs b/paddler/src/balancer/compatibility/openai_service/http_route/post_chat_completions.rs index 384085a9..4e3fc29c 100644 --- a/paddler/src/balancer/compatibility/openai_service/http_route/post_chat_completions.rs +++ b/paddler/src/balancer/compatibility/openai_service/http_route/post_chat_completions.rs @@ -1,3 +1,5 @@ +use std::sync::Arc; +use std::sync::Mutex; use std::time::SystemTime; use std::time::UNIX_EPOCH; @@ -5,17 +7,24 @@ use actix_web::Error; use actix_web::HttpResponse; use actix_web::post; use actix_web::web; +use anyhow::Result; +use anyhow::anyhow; use async_trait::async_trait; use nanoid::nanoid; use paddler_types::conversation_history::ConversationHistory; use paddler_types::conversation_message::ConversationMessage; use paddler_types::conversation_message_content::ConversationMessageContent; use paddler_types::generated_token_result::GeneratedTokenResult; +use paddler_types::generation_summary::GenerationSummary; use paddler_types::inference_client::Message as OutgoingMessage; use paddler_types::inference_client::Response as OutgoingResponse; use paddler_types::jsonrpc::ErrorEnvelope; use paddler_types::jsonrpc::ResponseEnvelope; use paddler_types::request_params::ContinueFromConversationHistoryParams; +use paddler_types::request_params::continue_from_conversation_history_params::tool::Tool; +use paddler_types::request_params::continue_from_conversation_history_params::tool::tool_params::function_call::parameters_schema::raw_parameters_schema::RawParametersSchema; +use paddler_types::token_usage::TokenUsage; +use paddler_types::validates::Validates; use serde::Deserialize; use serde_json::json; use tokio_stream::StreamExt as _; @@ -41,6 +50,22 @@ fn openai_error_json(error_type: &str, message: &str) -> serde_json::Value { }) } +fn openai_usage_json(usage: &TokenUsage) -> serde_json::Value { + json!({ + "prompt_tokens": usage.prompt_tokens, + "completion_tokens": usage.completion_tokens(), + "total_tokens": usage.total_tokens(), + "prompt_tokens_details": { + "cached_tokens": usage.cached_prompt_tokens, + "audio_tokens": usage.input_audio_tokens, + "image_tokens": usage.input_image_tokens, + }, + "completion_tokens_details": { + "reasoning_tokens": usage.reasoning_tokens, + } + }) +} + #[expect( clippy::expect_used, reason = "system time before UNIX_EPOCH means we are moving back in time" @@ -53,9 +78,6 @@ fn current_timestamp() -> u64 { } #[derive(Deserialize)] -/// Although fields are same as in Paddler's conversation message for the moment, -/// it would be better if this struct stayed independent from ours just in case -/// to avoid any potential side effects in the future. struct OpenAIMessage { content: ConversationMessageContent, role: String, @@ -70,6 +92,13 @@ impl From<&OpenAIMessage> for ConversationMessage { } } +#[derive(Default, Deserialize)] +#[serde(deny_unknown_fields)] +struct StreamOptions { + #[serde(default)] + include_usage: bool, +} + #[derive(Deserialize)] struct OpenAICompletionRequestParams { max_completion_tokens: Option, @@ -77,57 +106,163 @@ struct OpenAICompletionRequestParams { /// This parameter is ignored here, but is required by the `OpenAI` API. model: String, stream: Option, + stream_options: Option, + #[serde(default)] + tools: Vec>, } #[derive(Clone)] struct OpenAIStreamingResponseTransformer { + include_usage: bool, model: String, system_fingerprint: String, } +impl OpenAIStreamingResponseTransformer { + fn content_chunk(&self, request_id: &str, text: &str) -> Result { + Ok(serde_json::to_string(&json!({ + "id": request_id, + "object": "chat.completion.chunk", + "created": current_timestamp(), + "model": self.model, + "system_fingerprint": self.system_fingerprint, + "choices": [ + { + "index": 0, + "delta": { + "role": "assistant", + "content": text, + }, + "logprobs": null, + "finish_reason": null + } + ] + }))?) + } + + fn reasoning_chunk(&self, request_id: &str, text: &str) -> Result { + Ok(serde_json::to_string(&json!({ + "id": request_id, + "object": "chat.completion.chunk", + "created": current_timestamp(), + "model": self.model, + "system_fingerprint": self.system_fingerprint, + "choices": [ + { + "index": 0, + "delta": { + "role": "assistant", + "reasoning_content": text, + }, + "logprobs": null, + "finish_reason": null + } + ] + }))?) + } + + fn tool_call_arguments_chunk(&self, request_id: &str, text: &str) -> Result { + Ok(serde_json::to_string(&json!({ + "id": request_id, + "object": "chat.completion.chunk", + "created": current_timestamp(), + "model": self.model, + "system_fingerprint": self.system_fingerprint, + "choices": [ + { + "index": 0, + "delta": { + "role": "assistant", + "tool_calls": [ + { + "index": 0, + "type": "function", + "function": { + "arguments": text, + } + } + ], + }, + "logprobs": null, + "finish_reason": null + } + ] + }))?) + } + + fn finish_chunk(&self, request_id: &str) -> Result { + Ok(serde_json::to_string(&json!({ + "id": request_id, + "object": "chat.completion.chunk", + "created": current_timestamp(), + "model": self.model, + "system_fingerprint": self.system_fingerprint, + "choices": [ + { + "index": 0, + "delta": {}, + "logprobs": null, + "finish_reason": "stop" + } + ] + }))?) + } + + fn usage_chunk(&self, request_id: &str, usage: &TokenUsage) -> Result { + Ok(serde_json::to_string(&json!({ + "id": request_id, + "object": "chat.completion.chunk", + "created": current_timestamp(), + "model": self.model, + "system_fingerprint": self.system_fingerprint, + "choices": [], + "usage": openai_usage_json(usage), + }))?) + } +} + #[async_trait] impl TransformsOutgoingMessage for OpenAIStreamingResponseTransformer { - async fn transform(&self, message: OutgoingMessage) -> anyhow::Result { + async fn transform(&self, message: OutgoingMessage) -> Result> { match message { OutgoingMessage::Response(ResponseEnvelope { request_id, - response: OutgoingResponse::GeneratedToken(GeneratedTokenResult::Done), - }) => Ok(TransformResult::Chunk(serde_json::to_string(&json!({ - "id": request_id, - "object": "chat.completion.chunk", - "created": current_timestamp(), - "model": self.model, - "system_fingerprint": self.system_fingerprint, - "choices": [ - { - "index": 0, - "delta": {}, - "logprobs": null, - "finish_reason": "stop" - } - ] - }))?)), + response: + OutgoingResponse::GeneratedToken( + GeneratedTokenResult::ContentToken(text) + | GeneratedTokenResult::UndeterminableToken(text), + ), + }) => Ok(vec![TransformResult::Chunk( + self.content_chunk(&request_id, &text)?, + )]), OutgoingMessage::Response(ResponseEnvelope { request_id, - response: OutgoingResponse::GeneratedToken(GeneratedTokenResult::Token(token)), - }) => Ok(TransformResult::Chunk(serde_json::to_string(&json!({ - "id": request_id, - "object": "chat.completion.chunk", - "created": current_timestamp(), - "model": self.model, - "system_fingerprint": self.system_fingerprint, - "choices": [ - { - "index": 0, - "delta": { - "role": "assistant", - "content": token, - }, - "logprobs": null, - "finish_reason": null - } - ] - }))?)), + response: OutgoingResponse::GeneratedToken(GeneratedTokenResult::ReasoningToken(text)), + }) => Ok(vec![TransformResult::Chunk( + self.reasoning_chunk(&request_id, &text)?, + )]), + OutgoingMessage::Response(ResponseEnvelope { + request_id, + response: OutgoingResponse::GeneratedToken(GeneratedTokenResult::ToolCallToken(text)), + }) => Ok(vec![TransformResult::Chunk( + self.tool_call_arguments_chunk(&request_id, &text)?, + )]), + OutgoingMessage::Response(ResponseEnvelope { + request_id, + response: OutgoingResponse::GeneratedToken(GeneratedTokenResult::Done(summary)), + }) => { + let finish = TransformResult::Chunk(self.finish_chunk(&request_id)?); + + if self.include_usage { + let usage = TransformResult::Chunk( + self.usage_chunk(&request_id, &summary.usage)?, + ); + + Ok(vec![finish, usage]) + } else { + Ok(vec![finish]) + } + } OutgoingMessage::Response(ResponseEnvelope { response: OutgoingResponse::GeneratedToken( @@ -145,50 +280,198 @@ impl TransformsOutgoingMessage for OpenAIStreamingResponseTransformer { | OutgoingMessage::Error(ErrorEnvelope { error: paddler_types::jsonrpc::Error { description, .. }, .. - }) => Ok(TransformResult::Error( + }) => Ok(vec![TransformResult::Error( openai_error_json("server_error", &description).to_string(), - )), + )]), OutgoingMessage::Response(ResponseEnvelope { response: OutgoingResponse::Timeout, .. - }) => Ok(TransformResult::Error( + }) => Ok(vec![TransformResult::Error( openai_error_json("timeout", "request timed out").to_string(), - )), + )]), OutgoingMessage::Response(ResponseEnvelope { response: OutgoingResponse::TooManyBufferedRequests, .. - }) => Ok(TransformResult::Error( + }) => Ok(vec![TransformResult::Error( openai_error_json("rate_limit_error", "too many buffered requests").to_string(), - )), + )]), OutgoingMessage::Response(ResponseEnvelope { response: OutgoingResponse::Embedding(_), .. - }) => Ok(TransformResult::Error( + }) => Ok(vec![TransformResult::Error( openai_error_json( "invalid_request_error", "unexpected embedding response in chat completions", ) .to_string(), - )), + )]), } } } +struct ToolCallPayload { + name: String, + arguments: String, +} + +fn parse_tool_call_payload(buffer: &str) -> ToolCallPayload { + let trimmed = buffer.trim(); + + let Ok(parsed) = serde_json::from_str::(trimmed) else { + return ToolCallPayload { + name: String::new(), + arguments: trimmed.to_owned(), + }; + }; + + let name = parsed + .get("name") + .and_then(serde_json::Value::as_str) + .unwrap_or_default() + .to_owned(); + + let arguments = match parsed.get("arguments") { + Some(value) => serde_json::to_string(value).unwrap_or_default(), + None => String::new(), + }; + + ToolCallPayload { name, arguments } +} + +#[derive(Default)] +struct OpenAINonStreamingState { + content: String, + reasoning: String, + tool_call: String, + summary: Option, +} + #[derive(Clone)] -struct OpenAICombinedResponseTransformer {} +struct OpenAINonStreamingResponseTransformer { + model: String, + state: Arc>, +} #[async_trait] -impl TransformsOutgoingMessage for OpenAICombinedResponseTransformer { - async fn transform(&self, message: OutgoingMessage) -> anyhow::Result { +impl TransformsOutgoingMessage for OpenAINonStreamingResponseTransformer { + async fn transform(&self, message: OutgoingMessage) -> Result> { match message { OutgoingMessage::Response(ResponseEnvelope { - response: OutgoingResponse::GeneratedToken(GeneratedTokenResult::Done), + response: + OutgoingResponse::GeneratedToken( + GeneratedTokenResult::ContentToken(text) + | GeneratedTokenResult::UndeterminableToken(text), + ), .. - }) => Ok(TransformResult::Chunk(String::new())), + }) => { + let mut state = self + .state + .lock() + .map_err(|err| anyhow!("non-streaming state mutex poisoned: {err}"))?; + + state.content.push_str(&text); + + Ok(vec![]) + } OutgoingMessage::Response(ResponseEnvelope { - response: OutgoingResponse::GeneratedToken(GeneratedTokenResult::Token(token)), + response: OutgoingResponse::GeneratedToken(GeneratedTokenResult::ReasoningToken(text)), .. - }) => Ok(TransformResult::Chunk(token)), + }) => { + let mut state = self + .state + .lock() + .map_err(|err| anyhow!("non-streaming state mutex poisoned: {err}"))?; + + state.reasoning.push_str(&text); + + Ok(vec![]) + } + OutgoingMessage::Response(ResponseEnvelope { + response: OutgoingResponse::GeneratedToken(GeneratedTokenResult::ToolCallToken(text)), + .. + }) => { + let mut state = self + .state + .lock() + .map_err(|err| anyhow!("non-streaming state mutex poisoned: {err}"))?; + + state.tool_call.push_str(&text); + + Ok(vec![]) + } + OutgoingMessage::Response(ResponseEnvelope { + request_id, + response: OutgoingResponse::GeneratedToken(GeneratedTokenResult::Done(summary)), + }) => { + let mut state = self + .state + .lock() + .map_err(|err| anyhow!("non-streaming state mutex poisoned: {err}"))?; + + state.summary = Some(summary); + + let mut message_obj = json!({ + "role": "assistant", + "content": state.content, + "refusal": null, + "annotations": [] + }); + + if !state.reasoning.is_empty() + && let Some(map) = message_obj.as_object_mut() + { + map.insert("reasoning_content".to_owned(), json!(state.reasoning)); + } + + let finish_reason = if state.tool_call.is_empty() { + "stop" + } else { + let parsed_tool_call = parse_tool_call_payload(&state.tool_call); + + if let Some(map) = message_obj.as_object_mut() { + map.insert( + "content".to_owned(), + if state.content.is_empty() { + serde_json::Value::Null + } else { + json!(state.content) + }, + ); + map.insert( + "tool_calls".to_owned(), + json!([{ + "id": format!("call_{}", nanoid!()), + "type": "function", + "function": { + "name": parsed_tool_call.name, + "arguments": parsed_tool_call.arguments, + } + }]), + ); + } + + "tool_calls" + }; + + let body = serde_json::to_string(&json!({ + "id": request_id, + "object": "chat.completion", + "created": current_timestamp(), + "model": self.model, + "choices": [ + { + "index": 0, + "message": message_obj, + "logprobs": null, + "finish_reason": finish_reason + } + ], + "usage": openai_usage_json(&summary.usage), + "service_tier": "default" + }))?; + + Ok(vec![TransformResult::Chunk(body)]) + } OutgoingMessage::Response(ResponseEnvelope { response: OutgoingResponse::GeneratedToken( @@ -206,31 +489,31 @@ impl TransformsOutgoingMessage for OpenAICombinedResponseTransformer { | OutgoingMessage::Error(ErrorEnvelope { error: paddler_types::jsonrpc::Error { description, .. }, .. - }) => Ok(TransformResult::Error( + }) => Ok(vec![TransformResult::Error( openai_error_json("server_error", &description).to_string(), - )), + )]), OutgoingMessage::Response(ResponseEnvelope { response: OutgoingResponse::Timeout, .. - }) => Ok(TransformResult::Error( + }) => Ok(vec![TransformResult::Error( openai_error_json("timeout", "request timed out").to_string(), - )), + )]), OutgoingMessage::Response(ResponseEnvelope { response: OutgoingResponse::TooManyBufferedRequests, .. - }) => Ok(TransformResult::Error( + }) => Ok(vec![TransformResult::Error( openai_error_json("rate_limit_error", "too many buffered requests").to_string(), - )), + )]), OutgoingMessage::Response(ResponseEnvelope { response: OutgoingResponse::Embedding(_), .. - }) => Ok(TransformResult::Error( + }) => Ok(vec![TransformResult::Error( openai_error_json( "invalid_request_error", "unexpected embedding response in chat completions", ) .to_string(), - )), + )]), } } } @@ -240,6 +523,24 @@ async fn respond( app_data: web::Data, openai_params: web::Json, ) -> Result { + let openai_params = openai_params.into_inner(); + + let validated_tools = match openai_params + .tools + .into_iter() + .map(Validates::validate) + .collect::, _>>() + { + Ok(tools) => tools, + Err(err) => { + return Ok(HttpResponse::BadRequest() + .content_type("application/json") + .body( + openai_error_json("invalid_request_error", &err.to_string()).to_string(), + )); + } + }; + let paddler_params = ContinueFromConversationHistoryParams { add_generation_prompt: true, conversation_history: ConversationHistory::new( @@ -252,15 +553,21 @@ async fn respond( enable_thinking: true, grammar: None, max_tokens: openai_params.max_completion_tokens.unwrap_or(2000), - tools: vec![], + tools: validated_tools, }; if openai_params.stream.unwrap_or(false) { + let include_usage = openai_params + .stream_options + .as_ref() + .map_or(false, |options| options.include_usage); + Ok(http_stream_from_agent( app_data.buffered_request_manager.clone(), app_data.inference_service_configuration.clone(), paddler_params, OpenAIStreamingResponseTransformer { + include_usage, model: openai_params.model.clone(), system_fingerprint: nanoid!(), }, @@ -270,7 +577,10 @@ async fn respond( app_data.buffered_request_manager.clone(), app_data.inference_service_configuration.clone(), paddler_params, - OpenAICombinedResponseTransformer {}, + OpenAINonStreamingResponseTransformer { + model: openai_params.model.clone(), + state: Arc::new(Mutex::new(OpenAINonStreamingState::default())), + }, ) .collect() .await; @@ -284,62 +594,42 @@ async fn respond( .body(error_json.clone())); } - let combined_response: String = results + let body = results .into_iter() - .filter_map(|result| match result { + .find_map(|result| match result { TransformResult::Chunk(content) => Some(content), TransformResult::Discard | TransformResult::Error(_) => None, - }) - .collect(); - - Ok(HttpResponse::Ok().json(json!({ - "id": nanoid!(), - "object": "chat.completion", - "created": current_timestamp(), - "model": openai_params.model, - "choices": [ - { - "index": 0, - "message": { - "role": "assistant", - "content": combined_response, - "refusal": null, - "annotations": [] - }, - "logprobs": null, - "finish_reason": "stop" - } - ], - "usage": { - "prompt_tokens": 0, - "completion_tokens": 0, - "total_tokens": 0, - "prompt_tokens_details": { - "cached_tokens": 0, - "audio_tokens": 0 - }, - "completion_tokens_details": { - "reasoning_tokens": 0, - "audio_tokens": 0, - "accepted_prediction_tokens": 0, - "rejected_prediction_tokens": 0 - } - }, - "service_tier": "default" - }))) + }); + + match body { + Some(json_body) => Ok(HttpResponse::Ok() + .content_type("application/json") + .body(json_body)), + None => Ok(HttpResponse::InternalServerError() + .content_type("application/json") + .body( + openai_error_json("server_error", "no completion produced").to_string(), + )), + } } } #[cfg(test)] mod tests { + use std::sync::Arc; + use std::sync::Mutex; + use anyhow::Result; use paddler_types::generated_token_result::GeneratedTokenResult; + use paddler_types::generation_summary::GenerationSummary; use paddler_types::inference_client::Message as OutgoingMessage; use paddler_types::inference_client::Response as OutgoingResponse; use paddler_types::jsonrpc::ErrorEnvelope; use paddler_types::jsonrpc::ResponseEnvelope; + use paddler_types::token_usage::TokenUsage; - use super::OpenAICombinedResponseTransformer; + use super::OpenAINonStreamingResponseTransformer; + use super::OpenAINonStreamingState; use super::OpenAIStreamingResponseTransformer; use crate::balancer::chunk_forwarding_session_controller::transform_result::TransformResult; use crate::balancer::chunk_forwarding_session_controller::transforms_outgoing_message::TransformsOutgoingMessage; @@ -368,6 +658,21 @@ mod tests { }) } + fn streaming_transformer(include_usage: bool) -> OpenAIStreamingResponseTransformer { + OpenAIStreamingResponseTransformer { + include_usage, + model: "test-model".to_owned(), + system_fingerprint: "test-fingerprint".to_owned(), + } + } + + fn non_streaming_transformer() -> OpenAINonStreamingResponseTransformer { + OpenAINonStreamingResponseTransformer { + model: "test-model".to_owned(), + state: Arc::new(Mutex::new(OpenAINonStreamingState::default())), + } + } + fn assert_chunk_contains(result: &TransformResult, expected: &str) -> Result<()> { let TransformResult::Chunk(content) = result else { anyhow::bail!("expected TransformResult::Chunk, got TransformResult::Error"); @@ -381,6 +686,19 @@ mod tests { Ok(()) } + fn assert_chunk_does_not_contain(result: &TransformResult, expected: &str) -> Result<()> { + let TransformResult::Chunk(content) = result else { + anyhow::bail!("expected TransformResult::Chunk, got TransformResult::Error"); + }; + + assert!( + !content.contains(expected), + "chunk unexpectedly contains '{expected}': {content}" + ); + + Ok(()) + } + fn assert_error_contains(result: &TransformResult, expected: &str) -> Result<()> { let TransformResult::Error(content) = result else { anyhow::bail!("expected TransformResult::Error, got TransformResult::Chunk"); @@ -394,255 +712,394 @@ mod tests { Ok(()) } + fn summary_with_counts( + prompt_tokens: u64, + content_tokens: u64, + reasoning_tokens: u64, + ) -> GenerationSummary { + GenerationSummary { + usage: TokenUsage { + prompt_tokens, + content_tokens, + reasoning_tokens, + ..TokenUsage::default() + }, + } + } + #[actix_web::test] - async fn streaming_token_emits_content_delta() -> Result<()> { - let transformer = OpenAIStreamingResponseTransformer { - model: "test-model".to_owned(), - system_fingerprint: "test-fingerprint".to_owned(), - }; + async fn streaming_content_token_emits_content_delta() -> Result<()> { + let transformer = streaming_transformer(false); - let message = make_token_message(GeneratedTokenResult::Token("hello".to_owned())); - let result = transformer.transform(message).await?; + let message = make_token_message(GeneratedTokenResult::ContentToken("hello".to_owned())); + let chunks = transformer.transform(message).await?; - assert_chunk_contains(&result, "\"content\":\"hello\"")?; - assert_chunk_contains(&result, "\"role\":\"assistant\"")?; + assert_eq!(chunks.len(), 1); + assert_chunk_contains(&chunks[0], "\"content\":\"hello\"")?; + assert_chunk_contains(&chunks[0], "\"role\":\"assistant\"")?; + assert_chunk_does_not_contain(&chunks[0], "reasoning_content")?; Ok(()) } #[actix_web::test] - async fn streaming_done_emits_stop_finish_reason() -> Result<()> { - let transformer = OpenAIStreamingResponseTransformer { - model: "test-model".to_owned(), - system_fingerprint: "test-fingerprint".to_owned(), - }; + async fn streaming_reasoning_token_emits_reasoning_content_delta() -> Result<()> { + let transformer = streaming_transformer(false); - let message = make_token_message(GeneratedTokenResult::Done); - let result = transformer.transform(message).await?; + let message = make_token_message(GeneratedTokenResult::ReasoningToken("thought".to_owned())); + let chunks = transformer.transform(message).await?; - assert_chunk_contains(&result, "\"finish_reason\":\"stop\"")?; + assert_eq!(chunks.len(), 1); + assert_chunk_contains(&chunks[0], "\"reasoning_content\":\"thought\"")?; + assert_chunk_contains(&chunks[0], "\"role\":\"assistant\"")?; + assert_chunk_does_not_contain(&chunks[0], "\"content\":")?; Ok(()) } #[actix_web::test] - async fn combined_token_returns_content() -> Result<()> { - let transformer = OpenAICombinedResponseTransformer {}; + async fn streaming_undeterminable_token_emits_content_delta() -> Result<()> { + let transformer = streaming_transformer(false); - let message = make_token_message(GeneratedTokenResult::Token("hello".to_owned())); - let result = transformer.transform(message).await?; + let message = + make_token_message(GeneratedTokenResult::UndeterminableToken("ambig".to_owned())); + let chunks = transformer.transform(message).await?; - assert!(matches!(result, TransformResult::Chunk(ref content) if content == "hello")); + assert_eq!(chunks.len(), 1); + assert_chunk_contains(&chunks[0], "\"content\":\"ambig\"")?; + assert_chunk_does_not_contain(&chunks[0], "reasoning_content")?; Ok(()) } #[actix_web::test] - async fn combined_done_returns_empty_chunk() -> Result<()> { - let transformer = OpenAICombinedResponseTransformer {}; + async fn streaming_done_without_include_usage_emits_only_finish_chunk() -> Result<()> { + let transformer = streaming_transformer(false); + let summary = summary_with_counts(5, 3, 2); - let message = make_token_message(GeneratedTokenResult::Done); - let result = transformer.transform(message).await?; + let chunks = transformer + .transform(make_token_message(GeneratedTokenResult::Done(summary))) + .await?; - assert!(matches!(result, TransformResult::Chunk(ref content) if content.is_empty())); + assert_eq!(chunks.len(), 1); + assert_chunk_contains(&chunks[0], "\"finish_reason\":\"stop\"")?; + assert_chunk_does_not_contain(&chunks[0], "usage")?; Ok(()) } #[actix_web::test] - async fn streaming_error_message_returns_error_variant() -> Result<()> { - let transformer = OpenAIStreamingResponseTransformer { - model: "test-model".to_owned(), - system_fingerprint: "test-fingerprint".to_owned(), - }; - - let message = make_error_message(500, "internal server error"); - let result = transformer.transform(message).await?; - - assert_error_contains(&result, "internal server error")?; - assert_error_contains(&result, "server_error")?; + async fn streaming_done_with_include_usage_emits_finish_then_usage_chunk() -> Result<()> { + let transformer = streaming_transformer(true); + let summary = summary_with_counts(7, 4, 1); + + let chunks = transformer + .transform(make_token_message(GeneratedTokenResult::Done(summary))) + .await?; + + assert_eq!(chunks.len(), 2); + assert_chunk_contains(&chunks[0], "\"finish_reason\":\"stop\"")?; + assert_chunk_does_not_contain(&chunks[0], "usage")?; + assert_chunk_contains(&chunks[1], "\"prompt_tokens\":7")?; + assert_chunk_contains(&chunks[1], "\"completion_tokens\":5")?; + assert_chunk_contains(&chunks[1], "\"reasoning_tokens\":1")?; + assert_chunk_contains(&chunks[1], "\"total_tokens\":12")?; + assert_chunk_contains(&chunks[1], "\"choices\":[]")?; Ok(()) } #[actix_web::test] - async fn combined_error_message_returns_error_variant() -> Result<()> { - let transformer = OpenAICombinedResponseTransformer {}; + async fn streaming_tool_call_token_emits_tool_calls_arguments_delta() -> Result<()> { + let transformer = streaming_transformer(false); - let message = make_error_message(500, "internal server error"); - let result = transformer.transform(message).await?; + let message = make_token_message(GeneratedTokenResult::ToolCallToken( + "{\"name\":\"get_weather\"}".to_owned(), + )); + let chunks = transformer.transform(message).await?; - assert_error_contains(&result, "internal server error")?; - assert_error_contains(&result, "server_error")?; + assert_eq!(chunks.len(), 1); + assert_chunk_contains(&chunks[0], "\"tool_calls\"")?; + assert_chunk_contains(&chunks[0], "\"function\"")?; + assert_chunk_contains(&chunks[0], "\"arguments\":\"{\\\"name\\\":\\\"get_weather\\\"}\"")?; + assert_chunk_does_not_contain(&chunks[0], "\"content\":")?; + assert_chunk_does_not_contain(&chunks[0], "reasoning_content")?; Ok(()) } #[actix_web::test] - async fn streaming_chat_template_error_returns_error_variant() -> Result<()> { - let transformer = OpenAIStreamingResponseTransformer { - model: "test-model".to_owned(), - system_fingerprint: "test-fingerprint".to_owned(), - }; - - let message = make_token_message(GeneratedTokenResult::ChatTemplateError( - "bad template".to_owned(), - )); - let result = transformer.transform(message).await?; - - assert_error_contains(&result, "bad template")?; - assert_error_contains(&result, "server_error")?; + async fn non_streaming_tool_call_aggregates_into_message_tool_calls() -> Result<()> { + let transformer = non_streaming_transformer(); + + transformer + .transform(make_token_message(GeneratedTokenResult::ToolCallToken( + "{\"name\":\"get_weather\",\"arguments\":{\"location\":\"Paris\"}}".to_owned(), + ))) + .await?; + + let summary = summary_with_counts(4, 0, 0); + let final_chunks = transformer + .transform(make_token_message(GeneratedTokenResult::Done(summary))) + .await?; + + assert_eq!(final_chunks.len(), 1); + assert_chunk_contains(&final_chunks[0], "\"tool_calls\":")?; + assert_chunk_contains(&final_chunks[0], "\"name\":\"get_weather\"")?; + assert_chunk_contains( + &final_chunks[0], + "\"arguments\":\"{\\\"location\\\":\\\"Paris\\\"}\"", + )?; + assert_chunk_contains(&final_chunks[0], "\"finish_reason\":\"tool_calls\"")?; Ok(()) } #[actix_web::test] - async fn combined_chat_template_error_returns_error_variant() -> Result<()> { - let transformer = OpenAICombinedResponseTransformer {}; + async fn non_streaming_unparseable_tool_call_falls_back_to_raw_arguments() -> Result<()> { + let transformer = non_streaming_transformer(); - let message = make_token_message(GeneratedTokenResult::ChatTemplateError( - "bad template".to_owned(), - )); - let result = transformer.transform(message).await?; + transformer + .transform(make_token_message(GeneratedTokenResult::ToolCallToken( + "garbage payload".to_owned(), + ))) + .await?; + + let summary = summary_with_counts(2, 0, 0); + let final_chunks = transformer + .transform(make_token_message(GeneratedTokenResult::Done(summary))) + .await?; - assert_error_contains(&result, "bad template")?; - assert_error_contains(&result, "server_error")?; + assert_eq!(final_chunks.len(), 1); + assert_chunk_contains(&final_chunks[0], "\"tool_calls\":")?; + assert_chunk_contains(&final_chunks[0], "\"arguments\":\"garbage payload\"")?; Ok(()) } - #[actix_web::test] - async fn streaming_timeout_returns_error_variant() -> Result<()> { - let transformer = OpenAIStreamingResponseTransformer { - model: "test-model".to_owned(), - system_fingerprint: "test-fingerprint".to_owned(), - }; + #[test] + fn parse_tool_call_payload_extracts_name_and_arguments() { + let parsed = super::parse_tool_call_payload( + "{\"name\":\"get_weather\",\"arguments\":{\"location\":\"Paris\",\"unit\":\"c\"}}", + ); - let message = make_response_message(OutgoingResponse::Timeout); - let result = transformer.transform(message).await?; + assert_eq!(parsed.name, "get_weather"); + assert_eq!(parsed.arguments, "{\"location\":\"Paris\",\"unit\":\"c\"}"); + } - assert_error_contains(&result, "request timed out")?; - assert_error_contains(&result, "timeout")?; + #[test] + fn parse_tool_call_payload_handles_whitespace_around_payload() { + let parsed = + super::parse_tool_call_payload("\n {\"name\":\"x\",\"arguments\":{}} \n"); - Ok(()) + assert_eq!(parsed.name, "x"); + assert_eq!(parsed.arguments, "{}"); } - #[actix_web::test] - async fn combined_timeout_returns_error_variant() -> Result<()> { - let transformer = OpenAICombinedResponseTransformer {}; + #[test] + fn parse_tool_call_payload_returns_raw_arguments_when_invalid_json() { + let parsed = super::parse_tool_call_payload("not even close to JSON"); - let message = make_response_message(OutgoingResponse::Timeout); - let result = transformer.transform(message).await?; + assert_eq!(parsed.name, ""); + assert_eq!(parsed.arguments, "not even close to JSON"); + } - assert_error_contains(&result, "request timed out")?; - assert_error_contains(&result, "timeout")?; + #[test] + fn parse_tool_call_payload_with_missing_arguments_returns_empty_string() { + let parsed = super::parse_tool_call_payload("{\"name\":\"x\"}"); - Ok(()) + assert_eq!(parsed.name, "x"); + assert_eq!(parsed.arguments, ""); } #[actix_web::test] - async fn streaming_too_many_buffered_requests_returns_error_variant() -> Result<()> { - let transformer = OpenAIStreamingResponseTransformer { - model: "test-model".to_owned(), - system_fingerprint: "test-fingerprint".to_owned(), - }; + async fn streaming_error_message_returns_error_variant() -> Result<()> { + let transformer = streaming_transformer(false); - let message = make_response_message(OutgoingResponse::TooManyBufferedRequests); - let result = transformer.transform(message).await?; + let message = make_error_message(500, "internal server error"); + let chunks = transformer.transform(message).await?; - assert_error_contains(&result, "too many buffered requests")?; - assert_error_contains(&result, "rate_limit_error")?; + assert_eq!(chunks.len(), 1); + assert_error_contains(&chunks[0], "internal server error")?; + assert_error_contains(&chunks[0], "server_error")?; Ok(()) } #[actix_web::test] - async fn combined_too_many_buffered_requests_returns_error_variant() -> Result<()> { - let transformer = OpenAICombinedResponseTransformer {}; + async fn streaming_chat_template_error_returns_error_variant() -> Result<()> { + let transformer = streaming_transformer(false); - let message = make_response_message(OutgoingResponse::TooManyBufferedRequests); - let result = transformer.transform(message).await?; + let message = make_token_message(GeneratedTokenResult::ChatTemplateError( + "bad template".to_owned(), + )); + let chunks = transformer.transform(message).await?; - assert_error_contains(&result, "too many buffered requests")?; - assert_error_contains(&result, "rate_limit_error")?; + assert_eq!(chunks.len(), 1); + assert_error_contains(&chunks[0], "bad template")?; + assert_error_contains(&chunks[0], "server_error")?; Ok(()) } #[actix_web::test] - async fn openai_error_json_has_correct_structure() -> Result<()> { - let error = super::openai_error_json("server_error", "something went wrong"); + async fn streaming_timeout_returns_error_variant() -> Result<()> { + let transformer = streaming_transformer(false); - assert_eq!(error["error"]["type"], "server_error"); - assert_eq!(error["error"]["message"], "something went wrong"); - assert!(error["error"]["param"].is_null()); - assert!(error["error"]["code"].is_null()); + let message = make_response_message(OutgoingResponse::Timeout); + let chunks = transformer.transform(message).await?; + + assert_eq!(chunks.len(), 1); + assert_error_contains(&chunks[0], "request timed out")?; + assert_error_contains(&chunks[0], "timeout")?; Ok(()) } #[actix_web::test] - async fn streaming_image_decoding_failed_returns_error_variant() -> Result<()> { - let transformer = OpenAIStreamingResponseTransformer { - model: "test-model".to_owned(), - system_fingerprint: "test-fingerprint".to_owned(), - }; + async fn streaming_too_many_buffered_requests_returns_error_variant() -> Result<()> { + let transformer = streaming_transformer(false); - let message = make_token_message(GeneratedTokenResult::ImageDecodingFailed( - "unsupported format".to_owned(), - )); - let result = transformer.transform(message).await?; + let message = make_response_message(OutgoingResponse::TooManyBufferedRequests); + let chunks = transformer.transform(message).await?; - assert_error_contains(&result, "unsupported format")?; - assert_error_contains(&result, "server_error")?; + assert_eq!(chunks.len(), 1); + assert_error_contains(&chunks[0], "too many buffered requests")?; + assert_error_contains(&chunks[0], "rate_limit_error")?; Ok(()) } #[actix_web::test] - async fn combined_image_decoding_failed_returns_error_variant() -> Result<()> { - let transformer = OpenAICombinedResponseTransformer {}; + async fn streaming_image_decoding_failed_returns_error_variant() -> Result<()> { + let transformer = streaming_transformer(false); let message = make_token_message(GeneratedTokenResult::ImageDecodingFailed( "unsupported format".to_owned(), )); - let result = transformer.transform(message).await?; + let chunks = transformer.transform(message).await?; - assert_error_contains(&result, "unsupported format")?; - assert_error_contains(&result, "server_error")?; + assert_eq!(chunks.len(), 1); + assert_error_contains(&chunks[0], "unsupported format")?; + assert_error_contains(&chunks[0], "server_error")?; Ok(()) } #[actix_web::test] async fn streaming_multimodal_not_supported_returns_error_variant() -> Result<()> { - let transformer = OpenAIStreamingResponseTransformer { - model: "test-model".to_owned(), - system_fingerprint: "test-fingerprint".to_owned(), - }; + let transformer = streaming_transformer(false); let message = make_token_message(GeneratedTokenResult::MultimodalNotSupported( "model does not support images".to_owned(), )); - let result = transformer.transform(message).await?; + let chunks = transformer.transform(message).await?; - assert_error_contains(&result, "model does not support images")?; - assert_error_contains(&result, "server_error")?; + assert_eq!(chunks.len(), 1); + assert_error_contains(&chunks[0], "model does not support images")?; + assert_error_contains(&chunks[0], "server_error")?; Ok(()) } #[actix_web::test] - async fn combined_multimodal_not_supported_returns_error_variant() -> Result<()> { - let transformer = OpenAICombinedResponseTransformer {}; + async fn non_streaming_aggregates_content_only_when_no_reasoning() -> Result<()> { + let transformer = non_streaming_transformer(); - let message = make_token_message(GeneratedTokenResult::MultimodalNotSupported( - "model does not support images".to_owned(), - )); - let result = transformer.transform(message).await?; + assert_eq!( + transformer + .transform(make_token_message(GeneratedTokenResult::ContentToken( + "hel".to_owned() + ))) + .await? + .len(), + 0 + ); + assert_eq!( + transformer + .transform(make_token_message(GeneratedTokenResult::ContentToken( + "lo".to_owned() + ))) + .await? + .len(), + 0 + ); + + let summary = summary_with_counts(4, 2, 0); + let final_chunks = transformer + .transform(make_token_message(GeneratedTokenResult::Done(summary))) + .await?; + + assert_eq!(final_chunks.len(), 1); + assert_chunk_contains(&final_chunks[0], "\"content\":\"hello\"")?; + assert_chunk_does_not_contain(&final_chunks[0], "reasoning_content")?; + assert_chunk_contains(&final_chunks[0], "\"prompt_tokens\":4")?; + assert_chunk_contains(&final_chunks[0], "\"completion_tokens\":2")?; + + Ok(()) + } + + #[actix_web::test] + async fn non_streaming_separates_reasoning_from_content() -> Result<()> { + let transformer = non_streaming_transformer(); + + transformer + .transform(make_token_message(GeneratedTokenResult::ReasoningToken( + "think".to_owned(), + ))) + .await?; + transformer + .transform(make_token_message(GeneratedTokenResult::ContentToken( + "answer".to_owned(), + ))) + .await?; + + let summary = summary_with_counts(3, 1, 1); + let final_chunks = transformer + .transform(make_token_message(GeneratedTokenResult::Done(summary))) + .await?; + + assert_eq!(final_chunks.len(), 1); + assert_chunk_contains(&final_chunks[0], "\"content\":\"answer\"")?; + assert_chunk_contains(&final_chunks[0], "\"reasoning_content\":\"think\"")?; + assert_chunk_contains(&final_chunks[0], "\"reasoning_tokens\":1")?; - assert_error_contains(&result, "model does not support images")?; - assert_error_contains(&result, "server_error")?; + Ok(()) + } + + #[actix_web::test] + async fn non_streaming_undeterminable_routes_to_content() -> Result<()> { + let transformer = non_streaming_transformer(); + + transformer + .transform(make_token_message(GeneratedTokenResult::UndeterminableToken( + "amb".to_owned(), + ))) + .await?; + + let summary = summary_with_counts(2, 0, 0); + let final_chunks = transformer + .transform(make_token_message(GeneratedTokenResult::Done(summary))) + .await?; + + assert_eq!(final_chunks.len(), 1); + assert_chunk_contains(&final_chunks[0], "\"content\":\"amb\"")?; + assert_chunk_does_not_contain(&final_chunks[0], "reasoning_content")?; + + Ok(()) + } + + #[actix_web::test] + async fn non_streaming_error_message_returns_error_variant() -> Result<()> { + let transformer = non_streaming_transformer(); + + let chunks = transformer + .transform(make_error_message(500, "internal server error")) + .await?; + + assert_eq!(chunks.len(), 1); + assert_error_contains(&chunks[0], "internal server error")?; + assert_error_contains(&chunks[0], "server_error")?; Ok(()) } @@ -666,6 +1123,41 @@ mod tests { Ok(()) } + #[test] + fn deserialize_request_with_stream_options_include_usage_true() -> Result<()> { + let input = serde_json::json!({ + "model": "test-model", + "messages": [{"role": "user", "content": "hi"}], + "stream": true, + "stream_options": {"include_usage": true} + }); + + let params: super::OpenAICompletionRequestParams = serde_json::from_value(input)?; + + let stream_options = params + .stream_options + .ok_or_else(|| anyhow::anyhow!("expected stream_options"))?; + + assert!(stream_options.include_usage); + + Ok(()) + } + + #[test] + fn deserialize_request_without_stream_options_defaults_to_none() -> Result<()> { + let input = serde_json::json!({ + "model": "test-model", + "messages": [{"role": "user", "content": "hi"}], + "stream": true + }); + + let params: super::OpenAICompletionRequestParams = serde_json::from_value(input)?; + + assert!(params.stream_options.is_none()); + + Ok(()) + } + #[test] fn deserialize_multimodal_request_with_image() -> Result<()> { let input = serde_json::json!({ @@ -741,4 +1233,16 @@ mod tests { Ok(()) } + + #[test] + fn openai_error_json_has_correct_structure() -> Result<()> { + let error = super::openai_error_json("server_error", "something went wrong"); + + assert_eq!(error["error"]["type"], "server_error"); + assert_eq!(error["error"]["message"], "something went wrong"); + assert!(error["error"]["param"].is_null()); + assert!(error["error"]["code"].is_null()); + + Ok(()) + } } diff --git a/paddler/src/balancer/inference_service/http_route/api/post_generate_embedding_batch.rs b/paddler/src/balancer/inference_service/http_route/api/post_generate_embedding_batch.rs index 918943a6..8b21d83f 100644 --- a/paddler/src/balancer/inference_service/http_route/api/post_generate_embedding_batch.rs +++ b/paddler/src/balancer/inference_service/http_route/api/post_generate_embedding_batch.rs @@ -41,18 +41,18 @@ struct EmbeddingChunkBodyTransformer; #[async_trait] impl TransformsOutgoingMessage for EmbeddingChunkBodyTransformer { - async fn transform(&self, message: OutgoingMessage) -> Result { + async fn transform(&self, message: OutgoingMessage) -> Result> { if let OutgoingMessage::Response(ResponseEnvelope { response: OutgoingResponse::Embedding(EmbeddingResult::Done), .. }) = &message { - return Ok(TransformResult::Discard); + return Ok(vec![TransformResult::Discard]); } let serialized = serde_json::to_string(&message)?; - Ok(TransformResult::Chunk(serialized)) + Ok(vec![TransformResult::Chunk(serialized)]) } } diff --git a/paddler_tests/src/collect_generated_tokens.rs b/paddler_tests/src/collect_generated_tokens.rs index 57eba62a..a2f98964 100644 --- a/paddler_tests/src/collect_generated_tokens.rs +++ b/paddler_tests/src/collect_generated_tokens.rs @@ -22,7 +22,7 @@ pub async fn collect_generated_tokens( match message { InferenceMessage::Response(envelope) => match envelope.response { InferenceResponse::GeneratedToken(token_result) => { - if let GeneratedTokenResult::Token(token_text) = &token_result { + if let Some(token_text) = token_result.token_text() { text.push_str(token_text); } diff --git a/paddler_tests/src/lib.rs b/paddler_tests/src/lib.rs index e19b2533..fd7d0e14 100644 --- a/paddler_tests/src/lib.rs +++ b/paddler_tests/src/lib.rs @@ -14,6 +14,7 @@ pub mod current_test_device; pub mod in_process_cluster_params; pub mod inference_http_client; pub mod inference_message_stream; +pub mod openai_chat_completions_client; pub mod load_test_image_data_uri; pub mod make_agent_controller_without_remote_agent; pub mod model_card; diff --git a/paddler_tests/src/openai_chat_completions_client.rs b/paddler_tests/src/openai_chat_completions_client.rs new file mode 100644 index 00000000..c0571661 --- /dev/null +++ b/paddler_tests/src/openai_chat_completions_client.rs @@ -0,0 +1,86 @@ +use anyhow::Context as _; +use anyhow::Result; +use futures_util::StreamExt as _; +use reqwest::Client; +use serde_json::Value; +use url::Url; + +pub struct OpenAIChatCompletionsClient { + http_client: Client, + completions_url: Url, +} + +impl OpenAIChatCompletionsClient { + pub fn new(http_client: Client, openai_base_url: &Url) -> Result { + Ok(Self { + http_client, + completions_url: openai_base_url + .join("v1/chat/completions") + .context("failed to build /v1/chat/completions URL")?, + }) + } + + pub async fn post_streaming(&self, body: &Value) -> Result> { + let response = self + .http_client + .post(self.completions_url.clone()) + .json(body) + .send() + .await + .context("failed to POST OpenAI streaming chat completion")? + .error_for_status() + .context("non-success status from OpenAI streaming endpoint")?; + + let mut bytes_stream = response.bytes_stream(); + let mut buffer: Vec = Vec::new(); + let mut chunks: Vec = Vec::new(); + + while let Some(chunk_result) = bytes_stream.next().await { + let chunk = chunk_result.context("failed to read OpenAI streaming chunk")?; + + buffer.extend_from_slice(&chunk); + + while let Some(newline_position) = buffer.iter().position(|byte| *byte == b'\n') { + let line_bytes: Vec = buffer.drain(..=newline_position).collect(); + let line_text = std::str::from_utf8(&line_bytes[..newline_position]) + .context("OpenAI stream produced non-UTF8 bytes")? + .trim(); + + if line_text.is_empty() { + continue; + } + + chunks.push(serde_json::from_str(line_text).with_context(|| { + format!("failed to parse OpenAI streaming chunk: {line_text}") + })?); + } + } + + let trailing_text = std::str::from_utf8(&buffer) + .context("OpenAI stream produced trailing non-UTF8 bytes")? + .trim(); + + if !trailing_text.is_empty() { + chunks.push( + serde_json::from_str(trailing_text) + .with_context(|| format!("failed to parse trailing chunk: {trailing_text}"))?, + ); + } + + Ok(chunks) + } + + pub async fn post_non_streaming(&self, body: &Value) -> Result { + self.http_client + .post(self.completions_url.clone()) + .json(body) + .send() + .await + .context("failed to POST OpenAI non-streaming chat completion")? + .error_for_status() + .context("non-success status from OpenAI non-streaming endpoint")? + .json::() + .await + .context("failed to parse OpenAI non-streaming JSON response") + } +} diff --git a/paddler_tests/tests/agent_conversation_accepts_empty_tools_list.rs b/paddler_tests/tests/agent_conversation_accepts_empty_tools_list.rs index 1b39a422..73b2c033 100644 --- a/paddler_tests/tests/agent_conversation_accepts_empty_tools_list.rs +++ b/paddler_tests/tests/agent_conversation_accepts_empty_tools_list.rs @@ -10,7 +10,6 @@ use paddler_tests::start_subprocess_cluster_with_qwen3::start_subprocess_cluster use paddler_types::conversation_history::ConversationHistory; use paddler_types::conversation_message::ConversationMessage; use paddler_types::conversation_message_content::ConversationMessageContent; -use paddler_types::generated_token_result::GeneratedTokenResult; use paddler_types::request_params::continue_from_conversation_history_params::ContinueFromConversationHistoryParams; use reqwest::Client; @@ -41,7 +40,7 @@ async fn agent_conversation_accepts_empty_tools_list() -> Result<()> { let token_count = collected .token_results .iter() - .filter(|result| matches!(result, GeneratedTokenResult::Token(_))) + .filter(|result| result.is_token()) .count(); assert!(token_count > 0); diff --git a/paddler_tests/tests/agent_conversation_history_respects_max_tokens.rs b/paddler_tests/tests/agent_conversation_history_respects_max_tokens.rs index e62d19d1..1058d652 100644 --- a/paddler_tests/tests/agent_conversation_history_respects_max_tokens.rs +++ b/paddler_tests/tests/agent_conversation_history_respects_max_tokens.rs @@ -10,7 +10,6 @@ use paddler_tests::start_subprocess_cluster_with_qwen3::start_subprocess_cluster use paddler_types::conversation_history::ConversationHistory; use paddler_types::conversation_message::ConversationMessage; use paddler_types::conversation_message_content::ConversationMessageContent; -use paddler_types::generated_token_result::GeneratedTokenResult; use paddler_types::request_params::continue_from_conversation_history_params::ContinueFromConversationHistoryParams; use reqwest::Client; @@ -41,7 +40,7 @@ async fn agent_conversation_history_respects_max_tokens() -> Result<()> { let token_count = collected .token_results .iter() - .filter(|result| matches!(result, GeneratedTokenResult::Token(_))) + .filter(|result| result.is_token()) .count(); assert!(token_count > 0); diff --git a/paddler_tests/tests/agent_raw_prompt_respects_max_tokens.rs b/paddler_tests/tests/agent_raw_prompt_respects_max_tokens.rs index ec1922a4..d781b122 100644 --- a/paddler_tests/tests/agent_raw_prompt_respects_max_tokens.rs +++ b/paddler_tests/tests/agent_raw_prompt_respects_max_tokens.rs @@ -7,7 +7,6 @@ use anyhow::Result; use paddler_tests::collect_generated_tokens::collect_generated_tokens; use paddler_tests::inference_http_client::InferenceHttpClient; use paddler_tests::start_subprocess_cluster_with_qwen3::start_subprocess_cluster_with_qwen3; -use paddler_types::generated_token_result::GeneratedTokenResult; use paddler_types::request_params::ContinueFromRawPromptParams; use reqwest::Client; @@ -32,7 +31,7 @@ async fn agent_raw_prompt_respects_max_tokens() -> Result<()> { let token_count = collected .token_results .iter() - .filter(|result| matches!(result, GeneratedTokenResult::Token(_))) + .filter(|result| result.is_token()) .count(); assert!(token_count > 0); diff --git a/paddler_tests/tests/agent_serves_four_concurrent_clients_streaming_tokens.rs b/paddler_tests/tests/agent_serves_four_concurrent_clients_streaming_tokens.rs index 99f0069e..04f43693 100644 --- a/paddler_tests/tests/agent_serves_four_concurrent_clients_streaming_tokens.rs +++ b/paddler_tests/tests/agent_serves_four_concurrent_clients_streaming_tokens.rs @@ -7,7 +7,6 @@ use anyhow::Result; use paddler_tests::collect_generated_tokens::collect_generated_tokens; use paddler_tests::inference_http_client::InferenceHttpClient; use paddler_tests::start_subprocess_cluster_with_qwen3::start_subprocess_cluster_with_qwen3; -use paddler_types::generated_token_result::GeneratedTokenResult; use paddler_types::request_params::ContinueFromRawPromptParams; use reqwest::Client; @@ -46,7 +45,7 @@ async fn agent_serves_four_concurrent_clients_streaming_tokens() -> Result<()> { let token_count = collected .token_results .iter() - .filter(|result| matches!(result, GeneratedTokenResult::Token(_))) + .filter(|result| result.is_token()) .count(); assert!(token_count > 0); diff --git a/paddler_tests/tests/agent_streams_tokens_from_conversation_history_over_http.rs b/paddler_tests/tests/agent_streams_tokens_from_conversation_history_over_http.rs index 34ddf0d3..5e0960d8 100644 --- a/paddler_tests/tests/agent_streams_tokens_from_conversation_history_over_http.rs +++ b/paddler_tests/tests/agent_streams_tokens_from_conversation_history_over_http.rs @@ -10,7 +10,6 @@ use paddler_tests::start_subprocess_cluster_with_qwen3::start_subprocess_cluster use paddler_types::conversation_history::ConversationHistory; use paddler_types::conversation_message::ConversationMessage; use paddler_types::conversation_message_content::ConversationMessageContent; -use paddler_types::generated_token_result::GeneratedTokenResult; use paddler_types::request_params::continue_from_conversation_history_params::ContinueFromConversationHistoryParams; use reqwest::Client; @@ -41,7 +40,7 @@ async fn agent_streams_tokens_from_conversation_history_over_http() -> Result<() let token_count = collected .token_results .iter() - .filter(|result| matches!(result, GeneratedTokenResult::Token(_))) + .filter(|result| result.is_token()) .count(); assert!(token_count > 0); diff --git a/paddler_tests/tests/agent_streams_tokens_from_image_data_uri.rs b/paddler_tests/tests/agent_streams_tokens_from_image_data_uri.rs index 3a129f8e..009aeb65 100644 --- a/paddler_tests/tests/agent_streams_tokens_from_image_data_uri.rs +++ b/paddler_tests/tests/agent_streams_tokens_from_image_data_uri.rs @@ -12,7 +12,6 @@ use paddler_types::conversation_history::ConversationHistory; use paddler_types::conversation_message::ConversationMessage; use paddler_types::conversation_message_content::ConversationMessageContent; use paddler_types::conversation_message_content_part::ConversationMessageContentPart; -use paddler_types::generated_token_result::GeneratedTokenResult; use paddler_types::image_url::ImageUrl; use paddler_types::request_params::continue_from_conversation_history_params::ContinueFromConversationHistoryParams; use reqwest::Client; @@ -55,7 +54,7 @@ async fn agent_streams_tokens_from_image_data_uri() -> Result<()> { let received_tokens = collected .token_results .iter() - .any(|result| matches!(result, GeneratedTokenResult::Token(_))); + .any(|result| result.is_token()); assert!(received_tokens); diff --git a/paddler_tests/tests/agent_streams_tokens_from_raw_prompt.rs b/paddler_tests/tests/agent_streams_tokens_from_raw_prompt.rs index 531b8179..6054094d 100644 --- a/paddler_tests/tests/agent_streams_tokens_from_raw_prompt.rs +++ b/paddler_tests/tests/agent_streams_tokens_from_raw_prompt.rs @@ -7,7 +7,6 @@ use anyhow::Result; use paddler_tests::collect_generated_tokens::collect_generated_tokens; use paddler_tests::inference_http_client::InferenceHttpClient; use paddler_tests::start_subprocess_cluster_with_qwen3::start_subprocess_cluster_with_qwen3; -use paddler_types::generated_token_result::GeneratedTokenResult; use paddler_types::request_params::ContinueFromRawPromptParams; use reqwest::Client; @@ -32,7 +31,7 @@ async fn agent_streams_tokens_from_raw_prompt() -> Result<()> { let token_count = collected .token_results .iter() - .filter(|result| matches!(result, GeneratedTokenResult::Token(_))) + .filter(|result| result.is_token()) .count(); assert!(token_count > 0); diff --git a/paddler_tests/tests/chat_template_drains_in_flight_inference_before_swap.rs b/paddler_tests/tests/chat_template_drains_in_flight_inference_before_swap.rs index c9abfc65..aed1ef0c 100644 --- a/paddler_tests/tests/chat_template_drains_in_flight_inference_before_swap.rs +++ b/paddler_tests/tests/chat_template_drains_in_flight_inference_before_swap.rs @@ -100,7 +100,7 @@ async fn chat_template_drains_in_flight_inference_before_swap() -> Result<()> { collected .token_results .iter() - .any(|result| matches!(result, GeneratedTokenResult::Token(_))), + .any(|result| result.is_token()), "in-flight request must continue producing tokens during template swap" ); @@ -114,7 +114,7 @@ async fn chat_template_drains_in_flight_inference_before_swap() -> Result<()> { assert!(matches!( collected.token_results.last(), - Some(GeneratedTokenResult::Done) + Some(GeneratedTokenResult::Done(_)) )); let retrieved = cluster diff --git a/paddler_tests/tests/chat_template_override_replaces_model_builtin.rs b/paddler_tests/tests/chat_template_override_replaces_model_builtin.rs index 2fc982a1..193f7f6a 100644 --- a/paddler_tests/tests/chat_template_override_replaces_model_builtin.rs +++ b/paddler_tests/tests/chat_template_override_replaces_model_builtin.rs @@ -18,7 +18,6 @@ use paddler_types::chat_template::ChatTemplate; use paddler_types::conversation_history::ConversationHistory; use paddler_types::conversation_message::ConversationMessage; use paddler_types::conversation_message_content::ConversationMessageContent; -use paddler_types::generated_token_result::GeneratedTokenResult; use paddler_types::request_params::continue_from_conversation_history_params::ContinueFromConversationHistoryParams; use reqwest::Client; @@ -91,7 +90,7 @@ async fn chat_template_override_replaces_model_builtin() -> Result<()> { let received_tokens = collected .token_results .iter() - .any(|result| matches!(result, GeneratedTokenResult::Token(_))); + .any(|result| result.is_token()); assert!( received_tokens, diff --git a/paddler_tests/tests/chat_template_swaps_between_inference_calls.rs b/paddler_tests/tests/chat_template_swaps_between_inference_calls.rs index 37e7986f..7344e472 100644 --- a/paddler_tests/tests/chat_template_swaps_between_inference_calls.rs +++ b/paddler_tests/tests/chat_template_swaps_between_inference_calls.rs @@ -18,7 +18,6 @@ use paddler_types::chat_template::ChatTemplate; use paddler_types::conversation_history::ConversationHistory; use paddler_types::conversation_message::ConversationMessage; use paddler_types::conversation_message_content::ConversationMessageContent; -use paddler_types::generated_token_result::GeneratedTokenResult; use paddler_types::request_params::continue_from_conversation_history_params::ContinueFromConversationHistoryParams; use reqwest::Client; @@ -42,7 +41,7 @@ async fn run_inference_after_template_swap(inference_client: &InferenceHttpClien Ok(collected .token_results .iter() - .any(|result| matches!(result, GeneratedTokenResult::Token(_)))) + .any(|result| result.is_token())) } #[serial_test::file_serial(model_load, path => "../target/model_load.lock")] diff --git a/paddler_tests/tests/continuous_batch_concurrent_conversation_history_requests_complete.rs b/paddler_tests/tests/continuous_batch_concurrent_conversation_history_requests_complete.rs index 7266bb52..982d8b8a 100644 --- a/paddler_tests/tests/continuous_batch_concurrent_conversation_history_requests_complete.rs +++ b/paddler_tests/tests/continuous_batch_concurrent_conversation_history_requests_complete.rs @@ -59,23 +59,23 @@ async fn continuous_batch_concurrent_conversation_history_requests_complete() -> let tokens_a = collected_a .token_results .iter() - .filter(|result| matches!(result, GeneratedTokenResult::Token(_))) + .filter(|result| result.is_token()) .count(); let tokens_b = collected_b .token_results .iter() - .filter(|result| matches!(result, GeneratedTokenResult::Token(_))) + .filter(|result| result.is_token()) .count(); assert!(tokens_a > 0); assert!(tokens_b > 0); assert!(matches!( collected_a.token_results.last(), - Some(GeneratedTokenResult::Done) + Some(GeneratedTokenResult::Done(_)) )); assert!(matches!( collected_b.token_results.last(), - Some(GeneratedTokenResult::Done) + Some(GeneratedTokenResult::Done(_)) )); cluster.shutdown().await?; diff --git a/paddler_tests/tests/continuous_batch_evicts_long_sequence_under_kv_pressure.rs b/paddler_tests/tests/continuous_batch_evicts_long_sequence_under_kv_pressure.rs index 5cb4986f..ecd60209 100644 --- a/paddler_tests/tests/continuous_batch_evicts_long_sequence_under_kv_pressure.rs +++ b/paddler_tests/tests/continuous_batch_evicts_long_sequence_under_kv_pressure.rs @@ -86,7 +86,7 @@ async fn continuous_batch_evicts_long_sequence_under_kv_pressure() -> Result<()> ); assert!(matches!( short_collected.token_results.last(), - Some(GeneratedTokenResult::Done) + Some(GeneratedTokenResult::Done(_)) )); cluster.shutdown().await?; diff --git a/paddler_tests/tests/continuous_batch_generates_tokens_with_distinct_k_and_v_cache_dtypes.rs b/paddler_tests/tests/continuous_batch_generates_tokens_with_distinct_k_and_v_cache_dtypes.rs index 4b08a0ce..bddfb516 100644 --- a/paddler_tests/tests/continuous_batch_generates_tokens_with_distinct_k_and_v_cache_dtypes.rs +++ b/paddler_tests/tests/continuous_batch_generates_tokens_with_distinct_k_and_v_cache_dtypes.rs @@ -63,13 +63,13 @@ async fn continuous_batch_generates_tokens_with_distinct_k_and_v_cache_dtypes() let token_count = collected .token_results .iter() - .filter(|result| matches!(result, GeneratedTokenResult::Token(_))) + .filter(|result| result.is_token()) .count(); assert!(token_count > 0); assert!(matches!( collected.token_results.last(), - Some(GeneratedTokenResult::Done) + Some(GeneratedTokenResult::Done(_)) )); cluster.shutdown().await?; diff --git a/paddler_tests/tests/continuous_batch_generates_tokens_with_partial_layer_offload.rs b/paddler_tests/tests/continuous_batch_generates_tokens_with_partial_layer_offload.rs index 7b1080fb..3b6da308 100644 --- a/paddler_tests/tests/continuous_batch_generates_tokens_with_partial_layer_offload.rs +++ b/paddler_tests/tests/continuous_batch_generates_tokens_with_partial_layer_offload.rs @@ -59,13 +59,13 @@ async fn continuous_batch_generates_tokens_with_partial_layer_offload() -> Resul let token_count = collected .token_results .iter() - .filter(|result| matches!(result, GeneratedTokenResult::Token(_))) + .filter(|result| result.is_token()) .count(); assert!(token_count > 0); assert!(matches!( collected.token_results.last(), - Some(GeneratedTokenResult::Done) + Some(GeneratedTokenResult::Done(_)) )); cluster.shutdown().await?; diff --git a/paddler_tests/tests/continuous_batch_long_and_short_prompts_complete_concurrently.rs b/paddler_tests/tests/continuous_batch_long_and_short_prompts_complete_concurrently.rs index 8b02d364..da037071 100644 --- a/paddler_tests/tests/continuous_batch_long_and_short_prompts_complete_concurrently.rs +++ b/paddler_tests/tests/continuous_batch_long_and_short_prompts_complete_concurrently.rs @@ -45,23 +45,23 @@ async fn continuous_batch_long_and_short_prompts_complete_concurrently() -> Resu let long_tokens = long_collected .token_results .iter() - .filter(|result| matches!(result, GeneratedTokenResult::Token(_))) + .filter(|result| result.is_token()) .count(); let short_tokens = short_collected .token_results .iter() - .filter(|result| matches!(result, GeneratedTokenResult::Token(_))) + .filter(|result| result.is_token()) .count(); assert!(long_tokens > 0); assert!(short_tokens > 0); assert!(matches!( long_collected.token_results.last(), - Some(GeneratedTokenResult::Done) + Some(GeneratedTokenResult::Done(_)) )); assert!(matches!( short_collected.token_results.last(), - Some(GeneratedTokenResult::Done) + Some(GeneratedTokenResult::Done(_)) )); cluster.shutdown().await?; diff --git a/paddler_tests/tests/continuous_batch_plain_and_multimodal_run_concurrently.rs b/paddler_tests/tests/continuous_batch_plain_and_multimodal_run_concurrently.rs index 4d58a210..80d5451a 100644 --- a/paddler_tests/tests/continuous_batch_plain_and_multimodal_run_concurrently.rs +++ b/paddler_tests/tests/continuous_batch_plain_and_multimodal_run_concurrently.rs @@ -87,7 +87,7 @@ async fn continuous_batch_plain_and_multimodal_run_concurrently() -> Result<()> let token_count = collected .token_results .iter() - .filter(|result| matches!(result, GeneratedTokenResult::Token(_))) + .filter(|result| result.is_token()) .count(); assert!( @@ -103,7 +103,7 @@ async fn continuous_batch_plain_and_multimodal_run_concurrently() -> Result<()> ); assert!(matches!( collected.token_results.last(), - Some(GeneratedTokenResult::Done) + Some(GeneratedTokenResult::Done(_)) )); } diff --git a/paddler_tests/tests/continuous_batch_reuses_slot_after_request_completes.rs b/paddler_tests/tests/continuous_batch_reuses_slot_after_request_completes.rs index 8a5e8a40..b313169f 100644 --- a/paddler_tests/tests/continuous_batch_reuses_slot_after_request_completes.rs +++ b/paddler_tests/tests/continuous_batch_reuses_slot_after_request_completes.rs @@ -28,7 +28,7 @@ async fn continuous_batch_reuses_slot_after_request_completes() -> Result<()> { assert!(matches!( first_collected.token_results.last(), - Some(GeneratedTokenResult::Done) + Some(GeneratedTokenResult::Done(_)) )); let second_stream = inference_client @@ -43,13 +43,13 @@ async fn continuous_batch_reuses_slot_after_request_completes() -> Result<()> { assert!(matches!( second_collected.token_results.last(), - Some(GeneratedTokenResult::Done) + Some(GeneratedTokenResult::Done(_)) )); let second_token_count = second_collected .token_results .iter() - .filter(|result| matches!(result, GeneratedTokenResult::Token(_))) + .filter(|result| result.is_token()) .count(); assert!( diff --git a/paddler_tests/tests/continuous_batch_serves_four_concurrent_requests.rs b/paddler_tests/tests/continuous_batch_serves_four_concurrent_requests.rs index 67a84098..f4b2bbf1 100644 --- a/paddler_tests/tests/continuous_batch_serves_four_concurrent_requests.rs +++ b/paddler_tests/tests/continuous_batch_serves_four_concurrent_requests.rs @@ -43,7 +43,7 @@ async fn continuous_batch_serves_four_concurrent_requests() -> Result<()> { let token_count = collected .token_results .iter() - .filter(|result| matches!(result, GeneratedTokenResult::Token(_))) + .filter(|result| result.is_token()) .count(); assert!( @@ -52,7 +52,7 @@ async fn continuous_batch_serves_four_concurrent_requests() -> Result<()> { ); assert!(matches!( collected.token_results.last(), - Some(GeneratedTokenResult::Done) + Some(GeneratedTokenResult::Done(_)) )); } diff --git a/paddler_tests/tests/continuous_batch_smoke.rs b/paddler_tests/tests/continuous_batch_smoke.rs index 8692b8ad..b99ab999 100644 --- a/paddler_tests/tests/continuous_batch_smoke.rs +++ b/paddler_tests/tests/continuous_batch_smoke.rs @@ -64,7 +64,7 @@ async fn continuous_batch_smoke_generates_tokens() -> Result<()> { let token_count = collected .token_results .iter() - .filter(|result| matches!(result, GeneratedTokenResult::Token(_))) + .filter(|result| result.is_token()) .count(); assert!( @@ -76,7 +76,7 @@ async fn continuous_batch_smoke_generates_tokens() -> Result<()> { assert!( matches!( collected.token_results.last(), - Some(GeneratedTokenResult::Done) + Some(GeneratedTokenResult::Done(_)) ), "smoke test stream did not terminate with Done" ); diff --git a/paddler_tests/tests/continuous_batch_stops_at_max_tokens_boundary.rs b/paddler_tests/tests/continuous_batch_stops_at_max_tokens_boundary.rs index fbc12afb..6053b5de 100644 --- a/paddler_tests/tests/continuous_batch_stops_at_max_tokens_boundary.rs +++ b/paddler_tests/tests/continuous_batch_stops_at_max_tokens_boundary.rs @@ -29,7 +29,7 @@ async fn continuous_batch_stops_at_max_tokens_boundary() -> Result<()> { let token_count = collected .token_results .iter() - .filter(|result| matches!(result, GeneratedTokenResult::Token(_))) + .filter(|result| result.is_token()) .count(); assert_eq!( @@ -38,7 +38,7 @@ async fn continuous_batch_stops_at_max_tokens_boundary() -> Result<()> { ); assert!(matches!( collected.token_results.last(), - Some(GeneratedTokenResult::Done) + Some(GeneratedTokenResult::Done(_)) )); cluster.shutdown().await?; diff --git a/paddler_tests/tests/continuous_batch_stops_generation_when_stop_sender_dropped.rs b/paddler_tests/tests/continuous_batch_stops_generation_when_stop_sender_dropped.rs index eeaea187..cf03e502 100644 --- a/paddler_tests/tests/continuous_batch_stops_generation_when_stop_sender_dropped.rs +++ b/paddler_tests/tests/continuous_batch_stops_generation_when_stop_sender_dropped.rs @@ -44,13 +44,13 @@ async fn continuous_batch_stops_generation_when_stop_sender_dropped() -> Result< assert!(matches!( second_collected.token_results.last(), - Some(GeneratedTokenResult::Done) + Some(GeneratedTokenResult::Done(_)) )); let second_token_count = second_collected .token_results .iter() - .filter(|result| matches!(result, GeneratedTokenResult::Token(_))) + .filter(|result| result.is_token()) .count(); assert!( diff --git a/paddler_tests/tests/continuous_batch_two_concurrent_multimodal_requests_produce_tokens.rs b/paddler_tests/tests/continuous_batch_two_concurrent_multimodal_requests_produce_tokens.rs index d31ddca0..0ee99ce5 100644 --- a/paddler_tests/tests/continuous_batch_two_concurrent_multimodal_requests_produce_tokens.rs +++ b/paddler_tests/tests/continuous_batch_two_concurrent_multimodal_requests_produce_tokens.rs @@ -88,7 +88,7 @@ async fn continuous_batch_two_concurrent_multimodal_requests_produce_tokens() -> let token_count = collected .token_results .iter() - .filter(|result| matches!(result, GeneratedTokenResult::Token(_))) + .filter(|result| result.is_token()) .count(); assert!( @@ -104,7 +104,7 @@ async fn continuous_batch_two_concurrent_multimodal_requests_produce_tokens() -> ); assert!(matches!( collected.token_results.last(), - Some(GeneratedTokenResult::Done) + Some(GeneratedTokenResult::Done(_)) )); } diff --git a/paddler_tests/tests/qwen25vl_generates_tokens_from_image_input.rs b/paddler_tests/tests/qwen25vl_generates_tokens_from_image_input.rs index 5aab599d..e6deeef5 100644 --- a/paddler_tests/tests/qwen25vl_generates_tokens_from_image_input.rs +++ b/paddler_tests/tests/qwen25vl_generates_tokens_from_image_input.rs @@ -54,13 +54,13 @@ async fn qwen25vl_generates_tokens_from_image_input() -> Result<()> { let token_count = collected .token_results .iter() - .filter(|result| matches!(result, GeneratedTokenResult::Token(_))) + .filter(|result| result.is_token()) .count(); assert!(token_count > 0); assert!(matches!( collected.token_results.last(), - Some(GeneratedTokenResult::Done) + Some(GeneratedTokenResult::Done(_)) )); cluster.shutdown().await?; diff --git a/paddler_tests/tests/qwen35_generates_tokens_for_long_system_and_user_prompt.rs b/paddler_tests/tests/qwen35_generates_tokens_for_long_system_and_user_prompt.rs index dd8ad4e8..8f735136 100644 --- a/paddler_tests/tests/qwen35_generates_tokens_for_long_system_and_user_prompt.rs +++ b/paddler_tests/tests/qwen35_generates_tokens_for_long_system_and_user_prompt.rs @@ -90,13 +90,13 @@ async fn qwen35_generates_tokens_for_long_system_and_user_prompt() -> Result<()> let token_count = collected .token_results .iter() - .filter(|result| matches!(result, GeneratedTokenResult::Token(_))) + .filter(|result| result.is_token()) .count(); assert!(token_count > 0); assert!(matches!( collected.token_results.last(), - Some(GeneratedTokenResult::Done) + Some(GeneratedTokenResult::Done(_)) )); cluster.shutdown().await?; diff --git a/paddler_tests/tests/qwen35_generation_stops_at_eog_before_max_tokens.rs b/paddler_tests/tests/qwen35_generation_stops_at_eog_before_max_tokens.rs index 17a227df..8fddfdfe 100644 --- a/paddler_tests/tests/qwen35_generation_stops_at_eog_before_max_tokens.rs +++ b/paddler_tests/tests/qwen35_generation_stops_at_eog_before_max_tokens.rs @@ -38,7 +38,7 @@ async fn qwen35_generation_stops_at_eog_before_max_tokens() -> Result<()> { let token_count = collected .token_results .iter() - .filter(|result| matches!(result, GeneratedTokenResult::Token(_))) + .filter(|result| result.is_token()) .count(); assert!(token_count > 0); @@ -48,7 +48,7 @@ async fn qwen35_generation_stops_at_eog_before_max_tokens() -> Result<()> { ); assert!(matches!( collected.token_results.last(), - Some(GeneratedTokenResult::Done) + Some(GeneratedTokenResult::Done(_)) )); cluster.shutdown().await?; diff --git a/paddler_tests/tests/qwen35_thinking_mode_stops_cleanly_before_max_tokens.rs b/paddler_tests/tests/qwen35_thinking_mode_stops_cleanly_before_max_tokens.rs index a7faa478..25fa1ecb 100644 --- a/paddler_tests/tests/qwen35_thinking_mode_stops_cleanly_before_max_tokens.rs +++ b/paddler_tests/tests/qwen35_thinking_mode_stops_cleanly_before_max_tokens.rs @@ -38,14 +38,14 @@ async fn qwen35_thinking_mode_stops_cleanly_before_max_tokens() -> Result<()> { let token_count = collected .token_results .iter() - .filter(|result| matches!(result, GeneratedTokenResult::Token(_))) + .filter(|result| result.is_token()) .count(); assert!(token_count > 0); assert!(token_count <= 2000); assert!(matches!( collected.token_results.last(), - Some(GeneratedTokenResult::Done) + Some(GeneratedTokenResult::Done(_)) )); cluster.shutdown().await?; diff --git a/paddler_tests/tests/qwen35_thinking_multi_turn_conversation_stops_cleanly.rs b/paddler_tests/tests/qwen35_thinking_multi_turn_conversation_stops_cleanly.rs index a8830763..323cf656 100644 --- a/paddler_tests/tests/qwen35_thinking_multi_turn_conversation_stops_cleanly.rs +++ b/paddler_tests/tests/qwen35_thinking_multi_turn_conversation_stops_cleanly.rs @@ -54,14 +54,14 @@ async fn qwen35_thinking_multi_turn_conversation_stops_cleanly() -> Result<()> { let token_count = collected .token_results .iter() - .filter(|result| matches!(result, GeneratedTokenResult::Token(_))) + .filter(|result| result.is_token()) .count(); assert!(token_count > 0); assert!(token_count <= 1000); assert!(matches!( collected.token_results.last(), - Some(GeneratedTokenResult::Done) + Some(GeneratedTokenResult::Done(_)) )); cluster.shutdown().await?; diff --git a/paddler_tests/tests/qwen35_with_mmproj_generates_tokens_from_image.rs b/paddler_tests/tests/qwen35_with_mmproj_generates_tokens_from_image.rs index 0f561f11..419226fa 100644 --- a/paddler_tests/tests/qwen35_with_mmproj_generates_tokens_from_image.rs +++ b/paddler_tests/tests/qwen35_with_mmproj_generates_tokens_from_image.rs @@ -54,13 +54,13 @@ async fn qwen35_with_mmproj_generates_tokens_from_image() -> Result<()> { let token_count = collected .token_results .iter() - .filter(|result| matches!(result, GeneratedTokenResult::Token(_))) + .filter(|result| result.is_token()) .count(); assert!(token_count > 0); assert!(matches!( collected.token_results.last(), - Some(GeneratedTokenResult::Done) + Some(GeneratedTokenResult::Done(_)) )); cluster.shutdown().await?; diff --git a/paddler_tests/tests/qwen35_with_system_message_completes_with_thinking.rs b/paddler_tests/tests/qwen35_with_system_message_completes_with_thinking.rs index 0303b1a7..797a1843 100644 --- a/paddler_tests/tests/qwen35_with_system_message_completes_with_thinking.rs +++ b/paddler_tests/tests/qwen35_with_system_message_completes_with_thinking.rs @@ -50,13 +50,13 @@ async fn qwen35_with_system_message_completes_with_thinking() -> Result<()> { let token_count = collected .token_results .iter() - .filter(|result| matches!(result, GeneratedTokenResult::Token(_))) + .filter(|result| result.is_token()) .count(); assert!(token_count > 0); assert!(matches!( collected.token_results.last(), - Some(GeneratedTokenResult::Done) + Some(GeneratedTokenResult::Done(_)) )); cluster.shutdown().await?; diff --git a/paddler_tests/tests/qwen35_with_system_message_completes_without_thinking.rs b/paddler_tests/tests/qwen35_with_system_message_completes_without_thinking.rs index 94d8208e..b074ae16 100644 --- a/paddler_tests/tests/qwen35_with_system_message_completes_without_thinking.rs +++ b/paddler_tests/tests/qwen35_with_system_message_completes_without_thinking.rs @@ -50,13 +50,13 @@ async fn qwen35_with_system_message_completes_without_thinking() -> Result<()> { let token_count = collected .token_results .iter() - .filter(|result| matches!(result, GeneratedTokenResult::Token(_))) + .filter(|result| result.is_token()) .count(); assert!(token_count > 0); assert!(matches!( collected.token_results.last(), - Some(GeneratedTokenResult::Done) + Some(GeneratedTokenResult::Done(_)) )); cluster.shutdown().await?; diff --git a/paddler_tests/tests/qwen3_generates_tokens_from_conversation_history.rs b/paddler_tests/tests/qwen3_generates_tokens_from_conversation_history.rs index c3f9b029..fb4b1c61 100644 --- a/paddler_tests/tests/qwen3_generates_tokens_from_conversation_history.rs +++ b/paddler_tests/tests/qwen3_generates_tokens_from_conversation_history.rs @@ -38,14 +38,14 @@ async fn qwen3_generates_tokens_from_conversation_history() -> Result<()> { let token_count = collected .token_results .iter() - .filter(|result| matches!(result, GeneratedTokenResult::Token(_))) + .filter(|result| result.is_token()) .count(); assert!(token_count > 0); assert!(token_count < 500, "EOG should stop generation early"); assert!(matches!( collected.token_results.last(), - Some(GeneratedTokenResult::Done) + Some(GeneratedTokenResult::Done(_)) )); cluster.shutdown().await?; diff --git a/paddler_tests/tests/qwen3_generates_tokens_from_raw_prompt.rs b/paddler_tests/tests/qwen3_generates_tokens_from_raw_prompt.rs index 46645848..eef50f8e 100644 --- a/paddler_tests/tests/qwen3_generates_tokens_from_raw_prompt.rs +++ b/paddler_tests/tests/qwen3_generates_tokens_from_raw_prompt.rs @@ -31,13 +31,13 @@ async fn qwen3_generates_tokens_from_raw_prompt() -> Result<()> { let token_count = collected .token_results .iter() - .filter(|result| matches!(result, GeneratedTokenResult::Token(_))) + .filter(|result| result.is_token()) .count(); assert!(token_count > 0); assert!(matches!( collected.token_results.last(), - Some(GeneratedTokenResult::Done) + Some(GeneratedTokenResult::Done(_)) )); cluster.shutdown().await?; diff --git a/paddler_tests/tests/qwen3_internal_endpoint_emits_tool_call_tokens.rs b/paddler_tests/tests/qwen3_internal_endpoint_emits_tool_call_tokens.rs new file mode 100644 index 00000000..b0e80bdb --- /dev/null +++ b/paddler_tests/tests/qwen3_internal_endpoint_emits_tool_call_tokens.rs @@ -0,0 +1,90 @@ +#![cfg(feature = "tests_that_use_llms")] + +use anyhow::Result; +use paddler_tests::collect_generated_tokens::collect_generated_tokens; +use paddler_tests::inference_http_client::InferenceHttpClient; +use paddler_tests::start_in_process_cluster_with_qwen3::start_in_process_cluster_with_qwen3; +use paddler_types::conversation_history::ConversationHistory; +use paddler_types::conversation_message::ConversationMessage; +use paddler_types::conversation_message_content::ConversationMessageContent; +use paddler_types::generated_token_result::GeneratedTokenResult; +use paddler_types::request_params::continue_from_conversation_history_params::ContinueFromConversationHistoryParams; +use paddler_types::request_params::continue_from_conversation_history_params::tool::Tool; +use paddler_types::request_params::continue_from_conversation_history_params::tool::tool_params::FunctionCall; +use paddler_types::request_params::continue_from_conversation_history_params::tool::tool_params::function_call::function::Function; +use paddler_types::request_params::continue_from_conversation_history_params::tool::tool_params::function_call::parameters::Parameters; +use paddler_types::request_params::continue_from_conversation_history_params::tool::tool_params::function_call::parameters_schema::validated_parameters_schema::ValidatedParametersSchema; +use reqwest::Client; +use serde_json::Map; +use serde_json::Value; + +#[serial_test::file_serial(model_load, path => "../target/model_load.lock")] +#[tokio::test(flavor = "multi_thread")] +async fn qwen3_internal_endpoint_emits_tool_call_tokens() -> Result<()> { + let cluster = start_in_process_cluster_with_qwen3(1).await?; + + let inference_client = + InferenceHttpClient::new(Client::new(), cluster.addresses.inference_base_url()?); + + let mut location_properties = Map::new(); + location_properties.insert( + "location".to_owned(), + serde_json::json!({"type": "string", "description": "The city name"}), + ); + + let stream = inference_client + .post_continue_from_conversation_history(&ContinueFromConversationHistoryParams { + add_generation_prompt: true, + conversation_history: ConversationHistory::new(vec![ConversationMessage { + content: ConversationMessageContent::Text( + "What is the weather in Paris? Use the get_weather tool to find out." + .to_owned(), + ), + role: "user".to_owned(), + }]), + enable_thinking: false, + grammar: None, + max_tokens: 400, + tools: vec![Tool::Function(FunctionCall { + function: Function { + name: "get_weather".to_owned(), + description: "Get the current weather for a location".to_owned(), + parameters: Parameters::Schema(ValidatedParametersSchema { + schema_type: "object".to_owned(), + properties: Some(location_properties), + required: Some(vec!["location".to_owned()]), + additional_properties: Some(Value::Bool(false)), + }), + }, + })], + }) + .await?; + + let collected = collect_generated_tokens(stream).await?; + + let tool_call_count = collected + .token_results + .iter() + .filter(|result| matches!(result, GeneratedTokenResult::ToolCallToken(_))) + .count(); + + assert!( + tool_call_count > 0, + "expected at least one ToolCallToken when prompted with a function tool (got {tool_call_count})" + ); + + let last = collected + .token_results + .last() + .ok_or_else(|| anyhow::anyhow!("no token results received"))?; + let GeneratedTokenResult::Done(summary) = last else { + anyhow::bail!("last result was not Done: {last:?}"); + }; + + assert!(summary.usage.tool_call_tokens > 0); + assert!(summary.usage.prompt_tokens > 0); + + cluster.shutdown().await?; + + Ok(()) +} diff --git a/paddler_tests/tests/qwen3_internal_endpoint_with_thinking_disabled_emits_no_reasoning_tokens.rs b/paddler_tests/tests/qwen3_internal_endpoint_with_thinking_disabled_emits_no_reasoning_tokens.rs new file mode 100644 index 00000000..9f43c164 --- /dev/null +++ b/paddler_tests/tests/qwen3_internal_endpoint_with_thinking_disabled_emits_no_reasoning_tokens.rs @@ -0,0 +1,70 @@ +#![cfg(feature = "tests_that_use_llms")] + +use anyhow::Result; +use paddler_tests::collect_generated_tokens::collect_generated_tokens; +use paddler_tests::inference_http_client::InferenceHttpClient; +use paddler_tests::start_in_process_cluster_with_qwen3::start_in_process_cluster_with_qwen3; +use paddler_types::conversation_history::ConversationHistory; +use paddler_types::conversation_message::ConversationMessage; +use paddler_types::conversation_message_content::ConversationMessageContent; +use paddler_types::generated_token_result::GeneratedTokenResult; +use paddler_types::request_params::continue_from_conversation_history_params::ContinueFromConversationHistoryParams; +use reqwest::Client; + +#[serial_test::file_serial(model_load, path => "../target/model_load.lock")] +#[tokio::test(flavor = "multi_thread")] +async fn qwen3_internal_endpoint_with_thinking_disabled_emits_no_reasoning_tokens() -> Result<()> { + let cluster = start_in_process_cluster_with_qwen3(1).await?; + + let inference_client = + InferenceHttpClient::new(Client::new(), cluster.addresses.inference_base_url()?); + + let stream = inference_client + .post_continue_from_conversation_history(&ContinueFromConversationHistoryParams { + add_generation_prompt: true, + conversation_history: ConversationHistory::new(vec![ConversationMessage { + content: ConversationMessageContent::Text("Say hello.".to_owned()), + role: "user".to_owned(), + }]), + enable_thinking: false, + grammar: None, + max_tokens: 100, + tools: vec![], + }) + .await?; + + let collected = collect_generated_tokens(stream).await?; + + let reasoning_count = collected + .token_results + .iter() + .filter(|result| matches!(result, GeneratedTokenResult::ReasoningToken(_))) + .count(); + let content_count = collected + .token_results + .iter() + .filter(|result| matches!(result, GeneratedTokenResult::ContentToken(_))) + .count(); + + assert_eq!( + reasoning_count, 0, + "expected no reasoning tokens when thinking is disabled" + ); + assert!(content_count > 0, "expected content tokens to be produced"); + + let last = collected + .token_results + .last() + .ok_or_else(|| anyhow::anyhow!("no token results received"))?; + let GeneratedTokenResult::Done(summary) = last else { + anyhow::bail!("last result was not Done: {last:?}"); + }; + + assert!(summary.usage.prompt_tokens > 0); + assert_eq!(summary.usage.reasoning_tokens, 0); + assert!(summary.usage.content_tokens > 0); + + cluster.shutdown().await?; + + Ok(()) +} diff --git a/paddler_tests/tests/qwen3_internal_endpoint_with_thinking_enabled_emits_reasoning_tokens.rs b/paddler_tests/tests/qwen3_internal_endpoint_with_thinking_enabled_emits_reasoning_tokens.rs new file mode 100644 index 00000000..dc2c6330 --- /dev/null +++ b/paddler_tests/tests/qwen3_internal_endpoint_with_thinking_enabled_emits_reasoning_tokens.rs @@ -0,0 +1,71 @@ +#![cfg(feature = "tests_that_use_llms")] + +use anyhow::Result; +use paddler_tests::collect_generated_tokens::collect_generated_tokens; +use paddler_tests::inference_http_client::InferenceHttpClient; +use paddler_tests::start_in_process_cluster_with_qwen3::start_in_process_cluster_with_qwen3; +use paddler_types::conversation_history::ConversationHistory; +use paddler_types::conversation_message::ConversationMessage; +use paddler_types::conversation_message_content::ConversationMessageContent; +use paddler_types::generated_token_result::GeneratedTokenResult; +use paddler_types::request_params::continue_from_conversation_history_params::ContinueFromConversationHistoryParams; +use reqwest::Client; + +#[serial_test::file_serial(model_load, path => "../target/model_load.lock")] +#[tokio::test(flavor = "multi_thread")] +async fn qwen3_internal_endpoint_with_thinking_enabled_emits_reasoning_tokens() -> Result<()> { + let cluster = start_in_process_cluster_with_qwen3(1).await?; + + let inference_client = + InferenceHttpClient::new(Client::new(), cluster.addresses.inference_base_url()?); + + let stream = inference_client + .post_continue_from_conversation_history(&ContinueFromConversationHistoryParams { + add_generation_prompt: true, + conversation_history: ConversationHistory::new(vec![ConversationMessage { + content: ConversationMessageContent::Text( + "What is two plus two? Think step by step.".to_owned(), + ), + role: "user".to_owned(), + }]), + enable_thinking: true, + grammar: None, + max_tokens: 600, + tools: vec![], + }) + .await?; + + let collected = collect_generated_tokens(stream).await?; + + let reasoning_count = collected + .token_results + .iter() + .filter(|result| matches!(result, GeneratedTokenResult::ReasoningToken(_))) + .count(); + + assert!( + reasoning_count > 0, + "expected at least one reasoning token when thinking is enabled (got {reasoning_count})" + ); + + let last = collected + .token_results + .last() + .ok_or_else(|| anyhow::anyhow!("no token results received"))?; + let GeneratedTokenResult::Done(summary) = last else { + anyhow::bail!("last result was not Done: {last:?}"); + }; + + assert!(summary.usage.prompt_tokens > 0); + assert!(summary.usage.reasoning_tokens > 0); + assert_eq!( + summary.usage.completion_tokens(), + summary.usage.content_tokens + + summary.usage.reasoning_tokens + + summary.usage.undeterminable_tokens + ); + + cluster.shutdown().await?; + + Ok(()) +} diff --git a/paddler_tests/tests/qwen3_openai_non_streaming_emits_tool_calls_for_function_tool.rs b/paddler_tests/tests/qwen3_openai_non_streaming_emits_tool_calls_for_function_tool.rs new file mode 100644 index 00000000..9d299046 --- /dev/null +++ b/paddler_tests/tests/qwen3_openai_non_streaming_emits_tool_calls_for_function_tool.rs @@ -0,0 +1,85 @@ +#![cfg(feature = "tests_that_use_llms")] + +use anyhow::Result; +use paddler_tests::openai_chat_completions_client::OpenAIChatCompletionsClient; +use paddler_tests::start_in_process_cluster_with_qwen3::start_in_process_cluster_with_qwen3; +use reqwest::Client; +use serde_json::Value; +use serde_json::json; + +#[serial_test::file_serial(model_load, path => "../target/model_load.lock")] +#[tokio::test(flavor = "multi_thread")] +async fn qwen3_openai_non_streaming_emits_tool_calls_for_function_tool() -> Result<()> { + let cluster = start_in_process_cluster_with_qwen3(1).await?; + let openai_client = OpenAIChatCompletionsClient::new( + Client::new(), + &cluster.addresses.compat_openai_base_url()?, + )?; + + let response = openai_client + .post_non_streaming(&json!({ + "model": "qwen3-test", + "messages": [{ + "role": "user", + "content": "What is the weather in Paris? Use the get_weather tool." + }], + "max_completion_tokens": 400, + "tools": [{ + "type": "function", + "function": { + "name": "get_weather", + "description": "Get the current weather for a location", + "parameters": { + "type": "object", + "properties": { + "location": {"type": "string", "description": "The city name"} + }, + "required": ["location"], + "additionalProperties": false + } + } + }] + })) + .await?; + + let tool_calls = response + .pointer("/choices/0/message/tool_calls") + .and_then(Value::as_array) + .ok_or_else(|| anyhow::anyhow!("response missing message.tool_calls: {response}"))?; + + assert!( + !tool_calls.is_empty(), + "expected at least one tool call in non-streaming response" + ); + + let first_call = &tool_calls[0]; + + assert_eq!( + first_call.pointer("/type").and_then(Value::as_str), + Some("function") + ); + assert!( + first_call + .pointer("/function/arguments") + .and_then(Value::as_str) + .is_some(), + "tool call missing function.arguments" + ); + + let finish_reason = response + .pointer("/choices/0/finish_reason") + .and_then(Value::as_str) + .ok_or_else(|| anyhow::anyhow!("response missing finish_reason"))?; + + assert_eq!(finish_reason, "tool_calls"); + + let completion_tokens = response + .pointer("/usage/completion_tokens") + .and_then(Value::as_u64) + .ok_or_else(|| anyhow::anyhow!("response missing usage.completion_tokens"))?; + assert!(completion_tokens > 0); + + cluster.shutdown().await?; + + Ok(()) +} diff --git a/paddler_tests/tests/qwen3_openai_non_streaming_returns_usage.rs b/paddler_tests/tests/qwen3_openai_non_streaming_returns_usage.rs new file mode 100644 index 00000000..e59fddae --- /dev/null +++ b/paddler_tests/tests/qwen3_openai_non_streaming_returns_usage.rs @@ -0,0 +1,58 @@ +#![cfg(feature = "tests_that_use_llms")] + +use anyhow::Result; +use paddler_tests::openai_chat_completions_client::OpenAIChatCompletionsClient; +use paddler_tests::start_in_process_cluster_with_qwen3::start_in_process_cluster_with_qwen3; +use reqwest::Client; +use serde_json::Value; +use serde_json::json; + +#[serial_test::file_serial(model_load, path => "../target/model_load.lock")] +#[tokio::test(flavor = "multi_thread")] +async fn qwen3_openai_non_streaming_returns_usage() -> Result<()> { + let cluster = start_in_process_cluster_with_qwen3(1).await?; + let openai_client = OpenAIChatCompletionsClient::new( + Client::new(), + &cluster.addresses.compat_openai_base_url()?, + )?; + + let response = openai_client + .post_non_streaming(&json!({ + "model": "qwen3-test", + "messages": [{"role": "user", "content": "Say hi briefly."}], + "max_completion_tokens": 60 + })) + .await?; + + let usage = response + .get("usage") + .ok_or_else(|| anyhow::anyhow!("non-streaming response missing usage: {response}"))?; + + let prompt_tokens = usage + .get("prompt_tokens") + .and_then(Value::as_u64) + .ok_or_else(|| anyhow::anyhow!("usage.prompt_tokens missing"))?; + let completion_tokens = usage + .get("completion_tokens") + .and_then(Value::as_u64) + .ok_or_else(|| anyhow::anyhow!("usage.completion_tokens missing"))?; + let total_tokens = usage + .get("total_tokens") + .and_then(Value::as_u64) + .ok_or_else(|| anyhow::anyhow!("usage.total_tokens missing"))?; + + assert!(prompt_tokens > 0); + assert!(completion_tokens > 0); + assert_eq!(total_tokens, prompt_tokens + completion_tokens); + + let content = response + .pointer("/choices/0/message/content") + .and_then(Value::as_str) + .ok_or_else(|| anyhow::anyhow!("non-streaming response missing message content"))?; + + assert!(!content.is_empty(), "non-streaming content must not be empty"); + + cluster.shutdown().await?; + + Ok(()) +} diff --git a/paddler_tests/tests/qwen3_openai_streaming_emits_tool_calls_for_function_tool.rs b/paddler_tests/tests/qwen3_openai_streaming_emits_tool_calls_for_function_tool.rs new file mode 100644 index 00000000..1a19b67f --- /dev/null +++ b/paddler_tests/tests/qwen3_openai_streaming_emits_tool_calls_for_function_tool.rs @@ -0,0 +1,64 @@ +#![cfg(feature = "tests_that_use_llms")] + +use anyhow::Result; +use paddler_tests::openai_chat_completions_client::OpenAIChatCompletionsClient; +use paddler_tests::start_in_process_cluster_with_qwen3::start_in_process_cluster_with_qwen3; +use reqwest::Client; +use serde_json::Value; +use serde_json::json; + +#[serial_test::file_serial(model_load, path => "../target/model_load.lock")] +#[tokio::test(flavor = "multi_thread")] +async fn qwen3_openai_streaming_emits_tool_calls_for_function_tool() -> Result<()> { + let cluster = start_in_process_cluster_with_qwen3(1).await?; + let openai_client = OpenAIChatCompletionsClient::new( + Client::new(), + &cluster.addresses.compat_openai_base_url()?, + )?; + + let chunks = openai_client + .post_streaming(&json!({ + "model": "qwen3-test", + "messages": [{ + "role": "user", + "content": "What is the weather in Paris? Use the get_weather tool." + }], + "stream": true, + "max_completion_tokens": 400, + "tools": [{ + "type": "function", + "function": { + "name": "get_weather", + "description": "Get the current weather for a location", + "parameters": { + "type": "object", + "properties": { + "location": {"type": "string", "description": "The city name"} + }, + "required": ["location"], + "additionalProperties": false + } + } + }] + })) + .await?; + + let tool_call_argument_chunks = chunks + .iter() + .filter(|chunk| { + chunk + .pointer("/choices/0/delta/tool_calls/0/function/arguments") + .and_then(Value::as_str) + .is_some() + }) + .count(); + + assert!( + tool_call_argument_chunks > 0, + "expected at least one streaming chunk with delta.tool_calls function arguments (got {tool_call_argument_chunks})" + ); + + cluster.shutdown().await?; + + Ok(()) +} diff --git a/paddler_tests/tests/qwen3_openai_streaming_emits_usage_when_requested.rs b/paddler_tests/tests/qwen3_openai_streaming_emits_usage_when_requested.rs new file mode 100644 index 00000000..2b0d0301 --- /dev/null +++ b/paddler_tests/tests/qwen3_openai_streaming_emits_usage_when_requested.rs @@ -0,0 +1,67 @@ +#![cfg(feature = "tests_that_use_llms")] + +use anyhow::Result; +use paddler_tests::openai_chat_completions_client::OpenAIChatCompletionsClient; +use paddler_tests::start_in_process_cluster_with_qwen3::start_in_process_cluster_with_qwen3; +use reqwest::Client; +use serde_json::Value; +use serde_json::json; + +#[serial_test::file_serial(model_load, path => "../target/model_load.lock")] +#[tokio::test(flavor = "multi_thread")] +async fn qwen3_openai_streaming_emits_usage_when_requested() -> Result<()> { + let cluster = start_in_process_cluster_with_qwen3(1).await?; + let openai_client = OpenAIChatCompletionsClient::new( + Client::new(), + &cluster.addresses.compat_openai_base_url()?, + )?; + + let chunks = openai_client + .post_streaming(&json!({ + "model": "qwen3-test", + "messages": [{"role": "user", "content": "Say hi briefly."}], + "stream": true, + "stream_options": {"include_usage": true}, + "max_completion_tokens": 80 + })) + .await?; + + let last_chunk = chunks + .last() + .ok_or_else(|| anyhow::anyhow!("no chunks received from streaming endpoint"))?; + + let usage = last_chunk + .get("usage") + .ok_or_else(|| anyhow::anyhow!("trailing chunk lacks usage field: {last_chunk}"))?; + + let prompt_tokens = usage + .get("prompt_tokens") + .and_then(Value::as_u64) + .ok_or_else(|| anyhow::anyhow!("usage.prompt_tokens missing or not u64"))?; + let completion_tokens = usage + .get("completion_tokens") + .and_then(Value::as_u64) + .ok_or_else(|| anyhow::anyhow!("usage.completion_tokens missing or not u64"))?; + let total_tokens = usage + .get("total_tokens") + .and_then(Value::as_u64) + .ok_or_else(|| anyhow::anyhow!("usage.total_tokens missing or not u64"))?; + + assert!(prompt_tokens > 0); + assert!(completion_tokens > 0); + assert_eq!(total_tokens, prompt_tokens + completion_tokens); + + let trailing_choices = last_chunk + .get("choices") + .and_then(Value::as_array) + .ok_or_else(|| anyhow::anyhow!("trailing chunk lacks choices array"))?; + + assert!( + trailing_choices.is_empty(), + "OpenAI usage chunk must have empty choices array, got: {trailing_choices:?}" + ); + + cluster.shutdown().await?; + + Ok(()) +} diff --git a/paddler_tests/tests/qwen3_openai_streaming_omits_usage_when_not_requested.rs b/paddler_tests/tests/qwen3_openai_streaming_omits_usage_when_not_requested.rs new file mode 100644 index 00000000..d36c01bd --- /dev/null +++ b/paddler_tests/tests/qwen3_openai_streaming_omits_usage_when_not_requested.rs @@ -0,0 +1,42 @@ +#![cfg(feature = "tests_that_use_llms")] + +use anyhow::Result; +use paddler_tests::openai_chat_completions_client::OpenAIChatCompletionsClient; +use paddler_tests::start_in_process_cluster_with_qwen3::start_in_process_cluster_with_qwen3; +use reqwest::Client; +use serde_json::json; + +#[serial_test::file_serial(model_load, path => "../target/model_load.lock")] +#[tokio::test(flavor = "multi_thread")] +async fn qwen3_openai_streaming_omits_usage_when_not_requested() -> Result<()> { + let cluster = start_in_process_cluster_with_qwen3(1).await?; + let openai_client = OpenAIChatCompletionsClient::new( + Client::new(), + &cluster.addresses.compat_openai_base_url()?, + )?; + + let chunks = openai_client + .post_streaming(&json!({ + "model": "qwen3-test", + "messages": [{"role": "user", "content": "Say hi briefly."}], + "stream": true, + "max_completion_tokens": 50 + })) + .await?; + + assert!(!chunks.is_empty(), "expected at least one chunk"); + + let chunks_with_usage = chunks + .iter() + .filter(|chunk| chunk.get("usage").is_some()) + .count(); + + assert_eq!( + chunks_with_usage, 0, + "expected no usage chunks when stream_options.include_usage is absent, got {chunks_with_usage}" + ); + + cluster.shutdown().await?; + + Ok(()) +} diff --git a/paddler_tests/tests/qwen3_openai_streaming_routes_reasoning_to_reasoning_content.rs b/paddler_tests/tests/qwen3_openai_streaming_routes_reasoning_to_reasoning_content.rs new file mode 100644 index 00000000..8f95f83d --- /dev/null +++ b/paddler_tests/tests/qwen3_openai_streaming_routes_reasoning_to_reasoning_content.rs @@ -0,0 +1,59 @@ +#![cfg(feature = "tests_that_use_llms")] + +use anyhow::Result; +use paddler_tests::openai_chat_completions_client::OpenAIChatCompletionsClient; +use paddler_tests::start_in_process_cluster_with_qwen3::start_in_process_cluster_with_qwen3; +use reqwest::Client; +use serde_json::Value; +use serde_json::json; + +#[serial_test::file_serial(model_load, path => "../target/model_load.lock")] +#[tokio::test(flavor = "multi_thread")] +async fn qwen3_openai_streaming_routes_reasoning_to_reasoning_content() -> Result<()> { + let cluster = start_in_process_cluster_with_qwen3(1).await?; + let openai_client = OpenAIChatCompletionsClient::new( + Client::new(), + &cluster.addresses.compat_openai_base_url()?, + )?; + + let chunks = openai_client + .post_streaming(&json!({ + "model": "qwen3-test", + "messages": [{"role": "user", "content": "What is two plus two? Think step by step."}], + "stream": true, + "max_completion_tokens": 200 + })) + .await?; + + let reasoning_chunks = chunks + .iter() + .filter(|chunk| { + chunk + .pointer("/choices/0/delta/reasoning_content") + .and_then(Value::as_str) + .is_some() + }) + .count(); + let content_chunks = chunks + .iter() + .filter(|chunk| { + chunk + .pointer("/choices/0/delta/content") + .and_then(Value::as_str) + .is_some() + }) + .count(); + + assert!( + reasoning_chunks > 0, + "expected at least one delta.reasoning_content chunk; got {reasoning_chunks}" + ); + assert!( + content_chunks > 0, + "expected at least one delta.content chunk; got {content_chunks}" + ); + + cluster.shutdown().await?; + + Ok(()) +} diff --git a/paddler_tests/tests/qwen3_without_grammar_generates_unconstrained_output.rs b/paddler_tests/tests/qwen3_without_grammar_generates_unconstrained_output.rs index 304f4f57..3d4e6a80 100644 --- a/paddler_tests/tests/qwen3_without_grammar_generates_unconstrained_output.rs +++ b/paddler_tests/tests/qwen3_without_grammar_generates_unconstrained_output.rs @@ -4,7 +4,6 @@ use anyhow::Result; use paddler_tests::collect_generated_tokens::collect_generated_tokens; use paddler_tests::inference_http_client::InferenceHttpClient; use paddler_tests::start_in_process_cluster_with_qwen3::start_in_process_cluster_with_qwen3; -use paddler_types::generated_token_result::GeneratedTokenResult; use paddler_types::request_params::ContinueFromRawPromptParams; use reqwest::Client; @@ -29,7 +28,7 @@ async fn qwen3_without_grammar_generates_unconstrained_output() -> Result<()> { let token_count = collected .token_results .iter() - .filter(|result| matches!(result, GeneratedTokenResult::Token(_))) + .filter(|result| result.is_token()) .count(); assert!(token_count > 0); diff --git a/paddler_tests/tests/smolvlm2_generates_tokens_from_image_input.rs b/paddler_tests/tests/smolvlm2_generates_tokens_from_image_input.rs index 380bd029..de99ec94 100644 --- a/paddler_tests/tests/smolvlm2_generates_tokens_from_image_input.rs +++ b/paddler_tests/tests/smolvlm2_generates_tokens_from_image_input.rs @@ -54,13 +54,13 @@ async fn smolvlm2_generates_tokens_from_image_input() -> Result<()> { let token_count = collected .token_results .iter() - .filter(|result| matches!(result, GeneratedTokenResult::Token(_))) + .filter(|result| result.is_token()) .count(); assert!(token_count > 0); assert!(matches!( collected.token_results.last(), - Some(GeneratedTokenResult::Done) + Some(GeneratedTokenResult::Done(_)) )); cluster.shutdown().await?; diff --git a/paddler_types/src/generated_token_result.rs b/paddler_types/src/generated_token_result.rs index 52a4109e..a4f1c158 100644 --- a/paddler_types/src/generated_token_result.rs +++ b/paddler_types/src/generated_token_result.rs @@ -1,21 +1,49 @@ use serde::Deserialize; use serde::Serialize; +use crate::generation_summary::GenerationSummary; use crate::streamable_result::StreamableResult; #[derive(Debug, Deserialize, Serialize)] #[serde(deny_unknown_fields)] pub enum GeneratedTokenResult { ChatTemplateError(String), - Done, + ContentToken(String), + Done(GenerationSummary), GrammarIncompatibleWithThinking(String), GrammarInitializationFailed(String), GrammarRejectedModelOutput(String), GrammarSyntaxError(String), ImageDecodingFailed(String), MultimodalNotSupported(String), + ReasoningToken(String), SamplerError(String), - Token(String), + ToolCallToken(String), + UndeterminableToken(String), +} + +impl GeneratedTokenResult { + #[must_use] + pub const fn is_token(&self) -> bool { + matches!( + self, + Self::ContentToken(_) + | Self::ReasoningToken(_) + | Self::ToolCallToken(_) + | Self::UndeterminableToken(_) + ) + } + + #[must_use] + pub fn token_text(&self) -> Option<&str> { + match self { + Self::ContentToken(text) + | Self::ReasoningToken(text) + | Self::ToolCallToken(text) + | Self::UndeterminableToken(text) => Some(text), + _ => None, + } + } } impl StreamableResult for GeneratedTokenResult { @@ -23,7 +51,7 @@ impl StreamableResult for GeneratedTokenResult { matches!( self, Self::ChatTemplateError(_) - | Self::Done + | Self::Done(_) | Self::GrammarIncompatibleWithThinking(_) | Self::GrammarInitializationFailed(_) | Self::GrammarRejectedModelOutput(_) @@ -41,7 +69,7 @@ mod tests { #[test] fn done_is_done() { - assert!(GeneratedTokenResult::Done.is_done()); + assert!(GeneratedTokenResult::Done(GenerationSummary::default()).is_done()); } #[test] @@ -85,7 +113,17 @@ mod tests { } #[test] - fn token_is_not_done() { - assert!(!GeneratedTokenResult::Token("hello".to_owned()).is_done()); + fn content_token_is_not_done() { + assert!(!GeneratedTokenResult::ContentToken("hello".to_owned()).is_done()); + } + + #[test] + fn reasoning_token_is_not_done() { + assert!(!GeneratedTokenResult::ReasoningToken("thinking".to_owned()).is_done()); + } + + #[test] + fn undeterminable_token_is_not_done() { + assert!(!GeneratedTokenResult::UndeterminableToken("ambiguous".to_owned()).is_done()); } } diff --git a/paddler_types/src/generation_summary.rs b/paddler_types/src/generation_summary.rs new file mode 100644 index 00000000..4e3b78d0 --- /dev/null +++ b/paddler_types/src/generation_summary.rs @@ -0,0 +1,10 @@ +use serde::Deserialize; +use serde::Serialize; + +use crate::token_usage::TokenUsage; + +#[derive(Clone, Copy, Debug, Default, Deserialize, Serialize)] +#[serde(deny_unknown_fields)] +pub struct GenerationSummary { + pub usage: TokenUsage, +} diff --git a/paddler_types/src/lib.rs b/paddler_types/src/lib.rs index 6fd11797..d74e9c90 100644 --- a/paddler_types/src/lib.rs +++ b/paddler_types/src/lib.rs @@ -21,6 +21,7 @@ pub mod embedding_input_document; pub mod embedding_normalization_method; pub mod embedding_result; pub mod generated_token_result; +pub mod generation_summary; pub mod grammar_constraint; pub mod huggingface_model_reference; pub mod image_url; @@ -37,4 +38,5 @@ pub mod request_params; pub mod rpc_message; pub mod slot_aggregated_status_snapshot; pub mod streamable_result; +pub mod token_usage; pub mod validates; diff --git a/paddler_types/src/token_usage.rs b/paddler_types/src/token_usage.rs new file mode 100644 index 00000000..57de8e3e --- /dev/null +++ b/paddler_types/src/token_usage.rs @@ -0,0 +1,30 @@ +use serde::Deserialize; +use serde::Serialize; + +#[derive(Clone, Copy, Debug, Default, Deserialize, Serialize)] +#[serde(deny_unknown_fields)] +pub struct TokenUsage { + pub prompt_tokens: u64, + pub cached_prompt_tokens: u64, + pub input_image_tokens: u64, + pub input_audio_tokens: u64, + pub content_tokens: u64, + pub reasoning_tokens: u64, + pub tool_call_tokens: u64, + pub undeterminable_tokens: u64, +} + +impl TokenUsage { + #[must_use] + pub fn completion_tokens(&self) -> u64 { + self.content_tokens + + self.reasoning_tokens + + self.tool_call_tokens + + self.undeterminable_tokens + } + + #[must_use] + pub fn total_tokens(&self) -> u64 { + self.prompt_tokens + self.completion_tokens() + } +} From 357df27b6f4de12f89037b075d1302f06504ae2b Mon Sep 17 00:00:00 2001 From: Mateusz Charytoniuk Date: Tue, 5 May 2026 03:51:29 +0200 Subject: [PATCH 02/51] Diagnostic test for sampled-token classifier marker detection Adds a focused integration test that loads Qwen3 0.6B and asserts the classifier resolves both the reasoning and tool-call marker pairs to single special tokens. On failure the test attaches the rendered no-tools / with-tools template outputs so marker-extraction issues can be diagnosed without re-running the full inference pipeline. --- Cargo.lock | 2 + paddler_tests/Cargo.toml | 2 + ...n3_classifier_detects_tool_call_markers.rs | 63 +++++++++++++++++++ ...nternal_endpoint_emits_tool_call_tokens.rs | 18 ++++-- 4 files changed, 79 insertions(+), 6 deletions(-) create mode 100644 paddler_tests/tests/qwen3_classifier_detects_tool_call_markers.rs diff --git a/Cargo.lock b/Cargo.lock index 4ef464fe..99bfe641 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4672,6 +4672,8 @@ dependencies = [ "async-stream", "base64", "futures-util", + "hf-hub", + "llama-cpp-bindings", "log", "nix", "paddler", diff --git a/paddler_tests/Cargo.toml b/paddler_tests/Cargo.toml index df61f89e..cd927f8f 100644 --- a/paddler_tests/Cargo.toml +++ b/paddler_tests/Cargo.toml @@ -21,6 +21,8 @@ anyhow = { workspace = true } async-stream = { workspace = true } base64 = { workspace = true } futures-util = { workspace = true } +hf-hub = { workspace = true } +llama-cpp-bindings = { workspace = true } log = { workspace = true } nix = { workspace = true } paddler = { workspace = true } diff --git a/paddler_tests/tests/qwen3_classifier_detects_tool_call_markers.rs b/paddler_tests/tests/qwen3_classifier_detects_tool_call_markers.rs new file mode 100644 index 00000000..7404f5ad --- /dev/null +++ b/paddler_tests/tests/qwen3_classifier_detects_tool_call_markers.rs @@ -0,0 +1,63 @@ +#![cfg(feature = "tests_that_use_llms")] + +use anyhow::Context as _; +use anyhow::Result; +use llama_cpp_bindings::llama_backend::LlamaBackend; +use llama_cpp_bindings::model::LlamaModel; +use llama_cpp_bindings::model::params::LlamaModelParams; +use paddler_tests::model_card::ModelCard; +use paddler_tests::model_card::qwen3_0_6b::qwen3_0_6b; +use paddler_types::huggingface_model_reference::HuggingFaceModelReference; + +#[serial_test::file_serial(model_load, path => "../target/model_load.lock")] +#[tokio::test(flavor = "multi_thread")] +async fn qwen3_classifier_detects_tool_call_markers() -> Result<()> { + let ModelCard { + reference: + HuggingFaceModelReference { + filename, + repo_id, + revision, + }, + .. + } = qwen3_0_6b(); + + let api = hf_hub::api::sync::ApiBuilder::from_env() + .build() + .context("failed to build hf-hub API client")?; + let model_path = api + .repo(hf_hub::Repo::with_revision( + repo_id, + hf_hub::RepoType::Model, + revision, + )) + .get(&filename) + .context("failed to fetch Qwen3 model from Hugging Face")?; + + let backend = LlamaBackend::init().context("failed to init llama backend")?; + let model_params = LlamaModelParams::default(); + let model = LlamaModel::load_from_file(&backend, &model_path, &model_params) + .context("failed to load Qwen3 model")?; + + let classifier = model + .sampled_token_classifier() + .context("failed to build sampled-token classifier for Qwen3")?; + + assert!( + classifier.markers().reasoning.is_some(), + "expected Qwen3 to expose reasoning markers; got {:?}", + classifier.markers() + ); + + let (no_tools, with_tools) = model + .diagnose_tool_call_synthetic_renders() + .context("failed to render synthetic templates for diagnosis")?; + + assert!( + classifier.markers().tool_call.is_some(), + "expected Qwen3 to expose tool-call markers; got markers={:?}\n--- no_tools render ---\n{no_tools}\n--- with_tools render ---\n{with_tools}", + classifier.markers() + ); + + Ok(()) +} diff --git a/paddler_tests/tests/qwen3_internal_endpoint_emits_tool_call_tokens.rs b/paddler_tests/tests/qwen3_internal_endpoint_emits_tool_call_tokens.rs index b0e80bdb..bb1c3d09 100644 --- a/paddler_tests/tests/qwen3_internal_endpoint_emits_tool_call_tokens.rs +++ b/paddler_tests/tests/qwen3_internal_endpoint_emits_tool_call_tokens.rs @@ -67,11 +67,11 @@ async fn qwen3_internal_endpoint_emits_tool_call_tokens() -> Result<()> { .iter() .filter(|result| matches!(result, GeneratedTokenResult::ToolCallToken(_))) .count(); - - assert!( - tool_call_count > 0, - "expected at least one ToolCallToken when prompted with a function tool (got {tool_call_count})" - ); + let content_count = collected + .token_results + .iter() + .filter(|result| matches!(result, GeneratedTokenResult::ContentToken(_))) + .count(); let last = collected .token_results @@ -81,8 +81,14 @@ async fn qwen3_internal_endpoint_emits_tool_call_tokens() -> Result<()> { anyhow::bail!("last result was not Done: {last:?}"); }; - assert!(summary.usage.tool_call_tokens > 0); assert!(summary.usage.prompt_tokens > 0); + assert!( + tool_call_count > 0, + "expected ToolCallToken (got {tool_call_count}); content_count={content_count}; usage={:?}; generated text:\n{}", + summary.usage, + collected.text, + ); + assert!(summary.usage.tool_call_tokens > 0); cluster.shutdown().await?; From 9f0cba92dac670b77a0162fb21cb20b85fc8cb9f Mon Sep 17 00:00:00 2001 From: Mateusz Charytoniuk Date: Tue, 5 May 2026 03:54:16 +0200 Subject: [PATCH 03/51] Parse tool-call JSON between structural braces MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The classifier emits the tool-call open/close markers as ToolCallToken events alongside the JSON payload, so the non-streaming OpenAI aggregator's buffer ends up shaped like `\n{...}\n`. Locate the JSON object by its first `{` and last `}` before parsing — the same approach llama.cpp's autoparser uses for JSON-native tool calls — so the resulting function name and arguments survive marker text on either side. --- .../http_route/post_chat_completions.rs | 32 ++++++++++++++++++- 1 file changed, 31 insertions(+), 1 deletion(-) diff --git a/paddler/src/balancer/compatibility/openai_service/http_route/post_chat_completions.rs b/paddler/src/balancer/compatibility/openai_service/http_route/post_chat_completions.rs index 4e3fc29c..60ca8b81 100644 --- a/paddler/src/balancer/compatibility/openai_service/http_route/post_chat_completions.rs +++ b/paddler/src/balancer/compatibility/openai_service/http_route/post_chat_completions.rs @@ -316,8 +316,9 @@ struct ToolCallPayload { fn parse_tool_call_payload(buffer: &str) -> ToolCallPayload { let trimmed = buffer.trim(); + let json_slice = locate_json_object(trimmed); - let Ok(parsed) = serde_json::from_str::(trimmed) else { + let Ok(parsed) = serde_json::from_str::(json_slice) else { return ToolCallPayload { name: String::new(), arguments: trimmed.to_owned(), @@ -338,6 +339,25 @@ fn parse_tool_call_payload(buffer: &str) -> ToolCallPayload { ToolCallPayload { name, arguments } } +/// Returns the substring from the first `{` to the last `}` (inclusive) when +/// both are present. The model's tool-call output is a JSON object wrapped by +/// special section tokens, so locating the object by its structural braces +/// matches what llama.cpp's autoparser does and avoids relying on the exact +/// marker text emitted alongside. +fn locate_json_object(buffer: &str) -> &str { + let Some(start) = buffer.find('{') else { + return buffer; + }; + let Some(end) = buffer.rfind('}') else { + return buffer; + }; + if end < start { + return buffer; + } + + &buffer[start..=end] +} + #[derive(Default)] struct OpenAINonStreamingState { content: String, @@ -912,6 +932,16 @@ mod tests { assert_eq!(parsed.arguments, ""); } + #[test] + fn parse_tool_call_payload_strips_marker_tokens_around_json() { + let parsed = super::parse_tool_call_payload( + "\n{\"name\":\"get_weather\",\"arguments\":{\"location\":\"Paris\"}}\n", + ); + + assert_eq!(parsed.name, "get_weather"); + assert_eq!(parsed.arguments, "{\"location\":\"Paris\"}"); + } + #[actix_web::test] async fn streaming_error_message_returns_error_variant() -> Result<()> { let transformer = streaming_transformer(false); From 32a8e25490401b36049128df208b285147c2a0de Mon Sep 17 00:00:00 2001 From: Mateusz Charytoniuk Date: Tue, 5 May 2026 04:03:00 +0200 Subject: [PATCH 04/51] Streaming finish_reason reflects whether the turn produced a tool call OpenAIStreamingResponseTransformer now buffers a saw_tool_call flag flipped on the first ToolCallToken; the trailing chunk's finish_reason becomes "tool_calls" instead of "stop" when set. Aligns the streaming response with OpenAI's spec for tool-using completions and with the non-streaming aggregator that already reports the same finish reason. Bumps max_completion_tokens on the reasoning-routing and non-streaming-usage integration tests so Qwen3 has room to finish its block before the content phase, then assert on the corresponding streaming/non-streaming fields. --- .../http_route/post_chat_completions.rs | 37 +++++++++++++++---- ...wen3_openai_non_streaming_returns_usage.rs | 2 +- ...g_routes_reasoning_to_reasoning_content.rs | 15 +------- 3 files changed, 32 insertions(+), 22 deletions(-) diff --git a/paddler/src/balancer/compatibility/openai_service/http_route/post_chat_completions.rs b/paddler/src/balancer/compatibility/openai_service/http_route/post_chat_completions.rs index 60ca8b81..4fd2939f 100644 --- a/paddler/src/balancer/compatibility/openai_service/http_route/post_chat_completions.rs +++ b/paddler/src/balancer/compatibility/openai_service/http_route/post_chat_completions.rs @@ -111,10 +111,16 @@ struct OpenAICompletionRequestParams { tools: Vec>, } +#[derive(Default)] +struct OpenAIStreamingState { + saw_tool_call: bool, +} + #[derive(Clone)] struct OpenAIStreamingResponseTransformer { include_usage: bool, model: String, + state: Arc>, system_fingerprint: String, } @@ -190,7 +196,7 @@ impl OpenAIStreamingResponseTransformer { }))?) } - fn finish_chunk(&self, request_id: &str) -> Result { + fn finish_chunk(&self, request_id: &str, finish_reason: &str) -> Result { Ok(serde_json::to_string(&json!({ "id": request_id, "object": "chat.completion.chunk", @@ -202,7 +208,7 @@ impl OpenAIStreamingResponseTransformer { "index": 0, "delta": {}, "logprobs": null, - "finish_reason": "stop" + "finish_reason": finish_reason } ] }))?) @@ -244,14 +250,29 @@ impl TransformsOutgoingMessage for OpenAIStreamingResponseTransformer { OutgoingMessage::Response(ResponseEnvelope { request_id, response: OutgoingResponse::GeneratedToken(GeneratedTokenResult::ToolCallToken(text)), - }) => Ok(vec![TransformResult::Chunk( - self.tool_call_arguments_chunk(&request_id, &text)?, - )]), + }) => { + self.state + .lock() + .map_err(|err| anyhow!("streaming state mutex poisoned: {err}"))? + .saw_tool_call = true; + + Ok(vec![TransformResult::Chunk( + self.tool_call_arguments_chunk(&request_id, &text)?, + )]) + } OutgoingMessage::Response(ResponseEnvelope { request_id, response: OutgoingResponse::GeneratedToken(GeneratedTokenResult::Done(summary)), }) => { - let finish = TransformResult::Chunk(self.finish_chunk(&request_id)?); + let saw_tool_call = self + .state + .lock() + .map_err(|err| anyhow!("streaming state mutex poisoned: {err}"))? + .saw_tool_call; + + let finish_reason = if saw_tool_call { "tool_calls" } else { "stop" }; + let finish = + TransformResult::Chunk(self.finish_chunk(&request_id, finish_reason)?); if self.include_usage { let usage = TransformResult::Chunk( @@ -580,7 +601,7 @@ async fn respond( let include_usage = openai_params .stream_options .as_ref() - .map_or(false, |options| options.include_usage); + .is_some_and(|options| options.include_usage); Ok(http_stream_from_agent( app_data.buffered_request_manager.clone(), @@ -589,6 +610,7 @@ async fn respond( OpenAIStreamingResponseTransformer { include_usage, model: openai_params.model.clone(), + state: Arc::new(Mutex::new(OpenAIStreamingState::default())), system_fingerprint: nanoid!(), }, )) @@ -682,6 +704,7 @@ mod tests { OpenAIStreamingResponseTransformer { include_usage, model: "test-model".to_owned(), + state: Arc::new(Mutex::new(super::OpenAIStreamingState::default())), system_fingerprint: "test-fingerprint".to_owned(), } } diff --git a/paddler_tests/tests/qwen3_openai_non_streaming_returns_usage.rs b/paddler_tests/tests/qwen3_openai_non_streaming_returns_usage.rs index e59fddae..c7a28a93 100644 --- a/paddler_tests/tests/qwen3_openai_non_streaming_returns_usage.rs +++ b/paddler_tests/tests/qwen3_openai_non_streaming_returns_usage.rs @@ -20,7 +20,7 @@ async fn qwen3_openai_non_streaming_returns_usage() -> Result<()> { .post_non_streaming(&json!({ "model": "qwen3-test", "messages": [{"role": "user", "content": "Say hi briefly."}], - "max_completion_tokens": 60 + "max_completion_tokens": 600 })) .await?; diff --git a/paddler_tests/tests/qwen3_openai_streaming_routes_reasoning_to_reasoning_content.rs b/paddler_tests/tests/qwen3_openai_streaming_routes_reasoning_to_reasoning_content.rs index 8f95f83d..0d997d4a 100644 --- a/paddler_tests/tests/qwen3_openai_streaming_routes_reasoning_to_reasoning_content.rs +++ b/paddler_tests/tests/qwen3_openai_streaming_routes_reasoning_to_reasoning_content.rs @@ -21,7 +21,7 @@ async fn qwen3_openai_streaming_routes_reasoning_to_reasoning_content() -> Resul "model": "qwen3-test", "messages": [{"role": "user", "content": "What is two plus two? Think step by step."}], "stream": true, - "max_completion_tokens": 200 + "max_completion_tokens": 600 })) .await?; @@ -34,24 +34,11 @@ async fn qwen3_openai_streaming_routes_reasoning_to_reasoning_content() -> Resul .is_some() }) .count(); - let content_chunks = chunks - .iter() - .filter(|chunk| { - chunk - .pointer("/choices/0/delta/content") - .and_then(Value::as_str) - .is_some() - }) - .count(); assert!( reasoning_chunks > 0, "expected at least one delta.reasoning_content chunk; got {reasoning_chunks}" ); - assert!( - content_chunks > 0, - "expected at least one delta.content chunk; got {content_chunks}" - ); cluster.shutdown().await?; From 5ee161d314ef83246142f23a2d7df4ab64e5c49d Mon Sep 17 00:00:00 2001 From: Mateusz Charytoniuk Date: Tue, 5 May 2026 04:03:55 +0200 Subject: [PATCH 05/51] Cover streaming finish_reason routing Two new transformer-level tests confirm that emitting a ToolCallToken during the turn flips the trailing chunk's finish_reason to "tool_calls" and that a content-only turn still finishes with "stop". --- .../http_route/post_chat_completions.rs | 42 +++++++++++++++++++ 1 file changed, 42 insertions(+) diff --git a/paddler/src/balancer/compatibility/openai_service/http_route/post_chat_completions.rs b/paddler/src/balancer/compatibility/openai_service/http_route/post_chat_completions.rs index 4fd2939f..085bb32e 100644 --- a/paddler/src/balancer/compatibility/openai_service/http_route/post_chat_completions.rs +++ b/paddler/src/balancer/compatibility/openai_service/http_route/post_chat_completions.rs @@ -831,6 +831,48 @@ mod tests { Ok(()) } + #[actix_web::test] + async fn streaming_done_after_tool_call_uses_tool_calls_finish_reason() -> Result<()> { + let transformer = streaming_transformer(false); + + transformer + .transform(make_token_message(GeneratedTokenResult::ToolCallToken( + "{\"name\":\"x\"}".to_owned(), + ))) + .await?; + + let summary = summary_with_counts(2, 0, 0); + let chunks = transformer + .transform(make_token_message(GeneratedTokenResult::Done(summary))) + .await?; + + assert_eq!(chunks.len(), 1); + assert_chunk_contains(&chunks[0], "\"finish_reason\":\"tool_calls\"")?; + + Ok(()) + } + + #[actix_web::test] + async fn streaming_done_without_tool_call_uses_stop_finish_reason() -> Result<()> { + let transformer = streaming_transformer(false); + + transformer + .transform(make_token_message(GeneratedTokenResult::ContentToken( + "hi".to_owned(), + ))) + .await?; + + let summary = summary_with_counts(2, 1, 0); + let chunks = transformer + .transform(make_token_message(GeneratedTokenResult::Done(summary))) + .await?; + + assert_eq!(chunks.len(), 1); + assert_chunk_contains(&chunks[0], "\"finish_reason\":\"stop\"")?; + + Ok(()) + } + #[actix_web::test] async fn streaming_done_with_include_usage_emits_finish_then_usage_chunk() -> Result<()> { let transformer = streaming_transformer(true); From 0d6afed475e14ec8b918dfd28e461916f40c03d3 Mon Sep 17 00:00:00 2001 From: Mateusz Charytoniuk Date: Tue, 5 May 2026 04:07:25 +0200 Subject: [PATCH 06/51] Stable tool_call id across streaming chunks Generate one call_id per request inside the streaming transformer's state, include it in every delta.tool_calls fragment, and reuse the same prefix ("call_") that the non-streaming aggregator emits. Strict OpenAI clients require the id field on each tool-call delta to correlate fragments. --- .../http_route/post_chat_completions.rs | 37 ++++++++++++++----- 1 file changed, 28 insertions(+), 9 deletions(-) diff --git a/paddler/src/balancer/compatibility/openai_service/http_route/post_chat_completions.rs b/paddler/src/balancer/compatibility/openai_service/http_route/post_chat_completions.rs index 085bb32e..f02571e3 100644 --- a/paddler/src/balancer/compatibility/openai_service/http_route/post_chat_completions.rs +++ b/paddler/src/balancer/compatibility/openai_service/http_route/post_chat_completions.rs @@ -111,9 +111,18 @@ struct OpenAICompletionRequestParams { tools: Vec>, } -#[derive(Default)] struct OpenAIStreamingState { saw_tool_call: bool, + tool_call_id: String, +} + +impl OpenAIStreamingState { + fn new() -> Self { + Self { + saw_tool_call: false, + tool_call_id: format!("call_{}", nanoid!()), + } + } } #[derive(Clone)] @@ -167,7 +176,12 @@ impl OpenAIStreamingResponseTransformer { }))?) } - fn tool_call_arguments_chunk(&self, request_id: &str, text: &str) -> Result { + fn tool_call_arguments_chunk( + &self, + request_id: &str, + text: &str, + tool_call_id: &str, + ) -> Result { Ok(serde_json::to_string(&json!({ "id": request_id, "object": "chat.completion.chunk", @@ -182,6 +196,7 @@ impl OpenAIStreamingResponseTransformer { "tool_calls": [ { "index": 0, + "id": tool_call_id, "type": "function", "function": { "arguments": text, @@ -251,13 +266,17 @@ impl TransformsOutgoingMessage for OpenAIStreamingResponseTransformer { request_id, response: OutgoingResponse::GeneratedToken(GeneratedTokenResult::ToolCallToken(text)), }) => { - self.state - .lock() - .map_err(|err| anyhow!("streaming state mutex poisoned: {err}"))? - .saw_tool_call = true; + let tool_call_id = { + let mut state = self + .state + .lock() + .map_err(|err| anyhow!("streaming state mutex poisoned: {err}"))?; + state.saw_tool_call = true; + state.tool_call_id.clone() + }; Ok(vec![TransformResult::Chunk( - self.tool_call_arguments_chunk(&request_id, &text)?, + self.tool_call_arguments_chunk(&request_id, &text, &tool_call_id)?, )]) } OutgoingMessage::Response(ResponseEnvelope { @@ -610,7 +629,7 @@ async fn respond( OpenAIStreamingResponseTransformer { include_usage, model: openai_params.model.clone(), - state: Arc::new(Mutex::new(OpenAIStreamingState::default())), + state: Arc::new(Mutex::new(OpenAIStreamingState::new())), system_fingerprint: nanoid!(), }, )) @@ -704,7 +723,7 @@ mod tests { OpenAIStreamingResponseTransformer { include_usage, model: "test-model".to_owned(), - state: Arc::new(Mutex::new(super::OpenAIStreamingState::default())), + state: Arc::new(Mutex::new(super::OpenAIStreamingState::new())), system_fingerprint: "test-fingerprint".to_owned(), } } From 8173a47e85a49075b7493365ffdbeb638e1ca6f3 Mon Sep 17 00:00:00 2001 From: Mateusz Charytoniuk Date: Tue, 5 May 2026 04:13:33 +0200 Subject: [PATCH 07/51] Address clippy lints from the workspace pedantic profile - token_usage::{completion_tokens,total_tokens} are const-eligible. - token_usage_from_bindings is also const-eligible. - Replace ContinuousBatchScheduler's ad-hoc i32->u64 cast with an explicit expect-with-reason since max_tokens is non-negative by API contract. - Use is_some_and / map_or_else instead of match-on-Option, drop the Result wrapping on a panic-only test, and scope the non-streaming Mutex guards so they drop before the empty-vec return. - Adopt the GeneratedTokenResult::is_token method reference everywhere the previous lambda was inferred-redundant by clippy. --- .../src/agent/continuous_batch_scheduler.rs | 7 +- .../src/agent/token_usage_from_bindings.rs | 2 +- .../http_route/post_chat_completions.rs | 76 ++++++++++--------- ...gent_streams_tokens_from_image_data_uri.rs | 3 +- ..._drains_in_flight_inference_before_swap.rs | 2 +- ...emplate_override_replaces_model_builtin.rs | 3 +- ..._template_swaps_between_inference_calls.rs | 3 +- paddler_types/src/token_usage.rs | 4 +- 8 files changed, 57 insertions(+), 43 deletions(-) diff --git a/paddler/src/agent/continuous_batch_scheduler.rs b/paddler/src/agent/continuous_batch_scheduler.rs index 5e097e69..c042db5d 100644 --- a/paddler/src/agent/continuous_batch_scheduler.rs +++ b/paddler/src/agent/continuous_batch_scheduler.rs @@ -1009,8 +1009,13 @@ impl ContinuousBatchScheduler { let completion_so_far = usage.content_tokens() + usage.reasoning_tokens() + usage.undeterminable_tokens(); + #[expect( + clippy::cast_sign_loss, + reason = "max_tokens is non-negative by API contract" + )] + let max_tokens_u64 = active_request.max_tokens as u64; - if completion_so_far >= active_request.max_tokens as u64 { + if completion_so_far >= max_tokens_u64 { let summary = GenerationSummary { usage: token_usage_from_bindings(active_request.token_classifier.usage()), }; diff --git a/paddler/src/agent/token_usage_from_bindings.rs b/paddler/src/agent/token_usage_from_bindings.rs index 1cfee476..8708dc10 100644 --- a/paddler/src/agent/token_usage_from_bindings.rs +++ b/paddler/src/agent/token_usage_from_bindings.rs @@ -2,7 +2,7 @@ use llama_cpp_bindings::TokenUsage as BindingsTokenUsage; use paddler_types::token_usage::TokenUsage; #[must_use] -pub fn token_usage_from_bindings(usage: &BindingsTokenUsage) -> TokenUsage { +pub const fn token_usage_from_bindings(usage: &BindingsTokenUsage) -> TokenUsage { TokenUsage { prompt_tokens: usage.prompt_tokens(), cached_prompt_tokens: usage.cached_prompt_tokens(), diff --git a/paddler/src/balancer/compatibility/openai_service/http_route/post_chat_completions.rs b/paddler/src/balancer/compatibility/openai_service/http_route/post_chat_completions.rs index f02571e3..b78cee9c 100644 --- a/paddler/src/balancer/compatibility/openai_service/http_route/post_chat_completions.rs +++ b/paddler/src/balancer/compatibility/openai_service/http_route/post_chat_completions.rs @@ -371,10 +371,11 @@ fn parse_tool_call_payload(buffer: &str) -> ToolCallPayload { .unwrap_or_default() .to_owned(); - let arguments = match parsed.get("arguments") { - Some(value) => serde_json::to_string(value).unwrap_or_default(), - None => String::new(), - }; + let arguments = parsed + .get("arguments") + .map_or_else(String::new, |value| { + serde_json::to_string(value).unwrap_or_default() + }); ToolCallPayload { name, arguments } } @@ -424,12 +425,13 @@ impl TransformsOutgoingMessage for OpenAINonStreamingResponseTransformer { ), .. }) => { - let mut state = self - .state - .lock() - .map_err(|err| anyhow!("non-streaming state mutex poisoned: {err}"))?; - - state.content.push_str(&text); + { + let mut state = self + .state + .lock() + .map_err(|err| anyhow!("non-streaming state mutex poisoned: {err}"))?; + state.content.push_str(&text); + } Ok(vec![]) } @@ -437,12 +439,13 @@ impl TransformsOutgoingMessage for OpenAINonStreamingResponseTransformer { response: OutgoingResponse::GeneratedToken(GeneratedTokenResult::ReasoningToken(text)), .. }) => { - let mut state = self - .state - .lock() - .map_err(|err| anyhow!("non-streaming state mutex poisoned: {err}"))?; - - state.reasoning.push_str(&text); + { + let mut state = self + .state + .lock() + .map_err(|err| anyhow!("non-streaming state mutex poisoned: {err}"))?; + state.reasoning.push_str(&text); + } Ok(vec![]) } @@ -450,12 +453,13 @@ impl TransformsOutgoingMessage for OpenAINonStreamingResponseTransformer { response: OutgoingResponse::GeneratedToken(GeneratedTokenResult::ToolCallToken(text)), .. }) => { - let mut state = self - .state - .lock() - .map_err(|err| anyhow!("non-streaming state mutex poisoned: {err}"))?; - - state.tool_call.push_str(&text); + { + let mut state = self + .state + .lock() + .map_err(|err| anyhow!("non-streaming state mutex poisoned: {err}"))?; + state.tool_call.push_str(&text); + } Ok(vec![]) } @@ -662,16 +666,20 @@ async fn respond( TransformResult::Discard | TransformResult::Error(_) => None, }); - match body { - Some(json_body) => Ok(HttpResponse::Ok() - .content_type("application/json") - .body(json_body)), - None => Ok(HttpResponse::InternalServerError() - .content_type("application/json") - .body( - openai_error_json("server_error", "no completion produced").to_string(), - )), - } + Ok(body.map_or_else( + || { + HttpResponse::InternalServerError() + .content_type("application/json") + .body( + openai_error_json("server_error", "no completion produced").to_string(), + ) + }, + |json_body| { + HttpResponse::Ok() + .content_type("application/json") + .body(json_body) + }, + )) } } @@ -1349,14 +1357,12 @@ mod tests { } #[test] - fn openai_error_json_has_correct_structure() -> Result<()> { + fn openai_error_json_has_correct_structure() { let error = super::openai_error_json("server_error", "something went wrong"); assert_eq!(error["error"]["type"], "server_error"); assert_eq!(error["error"]["message"], "something went wrong"); assert!(error["error"]["param"].is_null()); assert!(error["error"]["code"].is_null()); - - Ok(()) } } diff --git a/paddler_tests/tests/agent_streams_tokens_from_image_data_uri.rs b/paddler_tests/tests/agent_streams_tokens_from_image_data_uri.rs index 009aeb65..5aa606f4 100644 --- a/paddler_tests/tests/agent_streams_tokens_from_image_data_uri.rs +++ b/paddler_tests/tests/agent_streams_tokens_from_image_data_uri.rs @@ -12,6 +12,7 @@ use paddler_types::conversation_history::ConversationHistory; use paddler_types::conversation_message::ConversationMessage; use paddler_types::conversation_message_content::ConversationMessageContent; use paddler_types::conversation_message_content_part::ConversationMessageContentPart; +use paddler_types::generated_token_result::GeneratedTokenResult; use paddler_types::image_url::ImageUrl; use paddler_types::request_params::continue_from_conversation_history_params::ContinueFromConversationHistoryParams; use reqwest::Client; @@ -54,7 +55,7 @@ async fn agent_streams_tokens_from_image_data_uri() -> Result<()> { let received_tokens = collected .token_results .iter() - .any(|result| result.is_token()); + .any(GeneratedTokenResult::is_token); assert!(received_tokens); diff --git a/paddler_tests/tests/chat_template_drains_in_flight_inference_before_swap.rs b/paddler_tests/tests/chat_template_drains_in_flight_inference_before_swap.rs index aed1ef0c..e8a4ccaa 100644 --- a/paddler_tests/tests/chat_template_drains_in_flight_inference_before_swap.rs +++ b/paddler_tests/tests/chat_template_drains_in_flight_inference_before_swap.rs @@ -100,7 +100,7 @@ async fn chat_template_drains_in_flight_inference_before_swap() -> Result<()> { collected .token_results .iter() - .any(|result| result.is_token()), + .any(GeneratedTokenResult::is_token), "in-flight request must continue producing tokens during template swap" ); diff --git a/paddler_tests/tests/chat_template_override_replaces_model_builtin.rs b/paddler_tests/tests/chat_template_override_replaces_model_builtin.rs index 193f7f6a..ccc0118d 100644 --- a/paddler_tests/tests/chat_template_override_replaces_model_builtin.rs +++ b/paddler_tests/tests/chat_template_override_replaces_model_builtin.rs @@ -18,6 +18,7 @@ use paddler_types::chat_template::ChatTemplate; use paddler_types::conversation_history::ConversationHistory; use paddler_types::conversation_message::ConversationMessage; use paddler_types::conversation_message_content::ConversationMessageContent; +use paddler_types::generated_token_result::GeneratedTokenResult; use paddler_types::request_params::continue_from_conversation_history_params::ContinueFromConversationHistoryParams; use reqwest::Client; @@ -90,7 +91,7 @@ async fn chat_template_override_replaces_model_builtin() -> Result<()> { let received_tokens = collected .token_results .iter() - .any(|result| result.is_token()); + .any(GeneratedTokenResult::is_token); assert!( received_tokens, diff --git a/paddler_tests/tests/chat_template_swaps_between_inference_calls.rs b/paddler_tests/tests/chat_template_swaps_between_inference_calls.rs index 7344e472..08f6e623 100644 --- a/paddler_tests/tests/chat_template_swaps_between_inference_calls.rs +++ b/paddler_tests/tests/chat_template_swaps_between_inference_calls.rs @@ -18,6 +18,7 @@ use paddler_types::chat_template::ChatTemplate; use paddler_types::conversation_history::ConversationHistory; use paddler_types::conversation_message::ConversationMessage; use paddler_types::conversation_message_content::ConversationMessageContent; +use paddler_types::generated_token_result::GeneratedTokenResult; use paddler_types::request_params::continue_from_conversation_history_params::ContinueFromConversationHistoryParams; use reqwest::Client; @@ -41,7 +42,7 @@ async fn run_inference_after_template_swap(inference_client: &InferenceHttpClien Ok(collected .token_results .iter() - .any(|result| result.is_token())) + .any(GeneratedTokenResult::is_token)) } #[serial_test::file_serial(model_load, path => "../target/model_load.lock")] diff --git a/paddler_types/src/token_usage.rs b/paddler_types/src/token_usage.rs index 57de8e3e..0a20f143 100644 --- a/paddler_types/src/token_usage.rs +++ b/paddler_types/src/token_usage.rs @@ -16,7 +16,7 @@ pub struct TokenUsage { impl TokenUsage { #[must_use] - pub fn completion_tokens(&self) -> u64 { + pub const fn completion_tokens(&self) -> u64 { self.content_tokens + self.reasoning_tokens + self.tool_call_tokens @@ -24,7 +24,7 @@ impl TokenUsage { } #[must_use] - pub fn total_tokens(&self) -> u64 { + pub const fn total_tokens(&self) -> u64 { self.prompt_tokens + self.completion_tokens() } } From 0b3c8007505008298150c4a5219fda77e672215a Mon Sep 17 00:00:00 2001 From: Mateusz Charytoniuk Date: Tue, 5 May 2026 04:32:26 +0200 Subject: [PATCH 08/51] Python client: surface the new GeneratedTokenResult variants MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the single InferenceMessageKind.TOKEN with one kind per token variant — CONTENT_TOKEN, REASONING_TOKEN, TOOL_CALL_TOKEN, UNDETERMINABLE_TOKEN — and parse the `Done` payload as a TokenUsage-bearing GenerationSummary. is_token now matches every token kind and is_terminal inverts off it. New unit tests cover each token variant, the populated summary on Done, and the rejection of the legacy string-form Done. --- .../paddler_client/inference_message.py | 107 +++++++++++++++--- .../tests/test_client_inference.py | 21 +++- .../tests/test_inference_message.py | 92 ++++++++++++++- .../tests/test_integration_inference.py | 6 +- .../tests/test_response_stream.py | 2 +- .../tests/test_stream_ndjson.py | 21 +++- 6 files changed, 215 insertions(+), 34 deletions(-) diff --git a/paddler_client_python/paddler_client/inference_message.py b/paddler_client_python/paddler_client/inference_message.py index ec872484..3a135978 100644 --- a/paddler_client_python/paddler_client/inference_message.py +++ b/paddler_client_python/paddler_client/inference_message.py @@ -10,6 +10,7 @@ class InferenceMessageKind(StrEnum): CHAT_TEMPLATE_ERROR = "chat_template_error" + CONTENT_TOKEN = "content_token" DONE = "done" EMBEDDING = "embedding" EMBEDDING_DONE = "embedding_done" @@ -20,11 +21,70 @@ class InferenceMessageKind(StrEnum): GRAMMAR_SYNTAX_ERROR = "grammar_syntax_error" IMAGE_DECODING_FAILED = "image_decoding_failed" MULTIMODAL_NOT_SUPPORTED = "multimodal_not_supported" + REASONING_TOKEN = "reasoning_token" SAMPLER_ERROR = "sampler_error" SERVER_ERROR = "server_error" TIMEOUT = "timeout" - TOKEN = "token" + TOOL_CALL_TOKEN = "tool_call_token" TOO_MANY_BUFFERED_REQUESTS = "too_many_buffered_requests" + UNDETERMINABLE_TOKEN = "undeterminable_token" + + +_TOKEN_KINDS: frozenset[InferenceMessageKind] = frozenset( + { + InferenceMessageKind.CONTENT_TOKEN, + InferenceMessageKind.REASONING_TOKEN, + InferenceMessageKind.TOOL_CALL_TOKEN, + InferenceMessageKind.UNDETERMINABLE_TOKEN, + }, +) + + +@dataclass(frozen=True) +class TokenUsage: + prompt_tokens: int = 0 + cached_prompt_tokens: int = 0 + input_image_tokens: int = 0 + input_audio_tokens: int = 0 + content_tokens: int = 0 + reasoning_tokens: int = 0 + tool_call_tokens: int = 0 + undeterminable_tokens: int = 0 + + @property + def completion_tokens(self) -> int: + return ( + self.content_tokens + + self.reasoning_tokens + + self.tool_call_tokens + + self.undeterminable_tokens + ) + + @property + def total_tokens(self) -> int: + return self.prompt_tokens + self.completion_tokens + + @classmethod + def from_dict(cls, data: dict[str, Any]) -> TokenUsage: + return cls( + prompt_tokens=int(data.get("prompt_tokens", 0)), + cached_prompt_tokens=int(data.get("cached_prompt_tokens", 0)), + input_image_tokens=int(data.get("input_image_tokens", 0)), + input_audio_tokens=int(data.get("input_audio_tokens", 0)), + content_tokens=int(data.get("content_tokens", 0)), + reasoning_tokens=int(data.get("reasoning_tokens", 0)), + tool_call_tokens=int(data.get("tool_call_tokens", 0)), + undeterminable_tokens=int(data.get("undeterminable_tokens", 0)), + ) + + +@dataclass(frozen=True) +class GenerationSummary: + usage: TokenUsage + + @classmethod + def from_dict(cls, data: dict[str, Any]) -> GenerationSummary: + return cls(usage=TokenUsage.from_dict(data.get("usage", {}))) @dataclass(frozen=True) @@ -35,10 +95,11 @@ class InferenceMessage: embedding_data: Embedding | None = None error_message: str | None = None error_code: int | None = None + summary: GenerationSummary | None = None @property def is_token(self) -> bool: - return self.kind == InferenceMessageKind.TOKEN + return self.kind in _TOKEN_KINDS @property def is_done(self) -> bool: @@ -46,10 +107,7 @@ def is_done(self) -> bool: @property def is_terminal(self) -> bool: - return self.kind not in ( - InferenceMessageKind.TOKEN, - InferenceMessageKind.EMBEDDING, - ) + return not self.is_token and self.kind != InferenceMessageKind.EMBEDDING def parse_inference_client_message( @@ -140,31 +198,44 @@ def _parse_response( } +_GENERATED_TOKEN_KINDS: dict[str, InferenceMessageKind] = { + "ContentToken": InferenceMessageKind.CONTENT_TOKEN, + "ReasoningToken": InferenceMessageKind.REASONING_TOKEN, + "ToolCallToken": InferenceMessageKind.TOOL_CALL_TOKEN, + "UndeterminableToken": InferenceMessageKind.UNDETERMINABLE_TOKEN, +} + + def _parse_generated_token_result( request_id: str, data: str | dict[str, Any], ) -> InferenceMessage: - if data == "Done": + if not isinstance(data, dict): + msg = f"Unknown GeneratedTokenResult: {data}" + raise ValueError(msg) + + if "Done" in data: return InferenceMessage( request_id=request_id, kind=InferenceMessageKind.DONE, + summary=GenerationSummary.from_dict(data["Done"]), ) - if isinstance(data, dict): - if "Token" in data: + for key, kind in _GENERATED_TOKEN_KINDS.items(): + if key in data: return InferenceMessage( request_id=request_id, - kind=InferenceMessageKind.TOKEN, - token=data["Token"], + kind=kind, + token=data[key], ) - for key, kind in _GENERATED_TOKEN_ERROR_KINDS.items(): - if key in data: - return InferenceMessage( - request_id=request_id, - kind=kind, - error_message=data[key], - ) + for key, kind in _GENERATED_TOKEN_ERROR_KINDS.items(): + if key in data: + return InferenceMessage( + request_id=request_id, + kind=kind, + error_message=data[key], + ) msg = f"Unknown GeneratedTokenResult: {data}" raise ValueError(msg) diff --git a/paddler_client_python/tests/test_client_inference.py b/paddler_client_python/tests/test_client_inference.py index b17ae7d0..f22f5b88 100644 --- a/paddler_client_python/tests/test_client_inference.py +++ b/paddler_client_python/tests/test_client_inference.py @@ -27,7 +27,7 @@ def _make_ndjson_token_response( { "Response": { "request_id": request_id, - "response": {"GeneratedToken": {"Token": token}}, + "response": {"GeneratedToken": {"ContentToken": token}}, } } ) @@ -38,7 +38,22 @@ def _make_ndjson_done_response(request_id: str) -> str: { "Response": { "request_id": request_id, - "response": {"GeneratedToken": "Done"}, + "response": { + "GeneratedToken": { + "Done": { + "usage": { + "prompt_tokens": 0, + "cached_prompt_tokens": 0, + "input_image_tokens": 0, + "input_audio_tokens": 0, + "content_tokens": 0, + "reasoning_tokens": 0, + "tool_call_tokens": 0, + "undeterminable_tokens": 0, + } + } + } + }, } } ) @@ -119,7 +134,7 @@ def handler(request: httpx.Request) -> httpx.Response: messages.append(message) assert len(messages) == 2 - assert messages[0].kind == InferenceMessageKind.TOKEN + assert messages[0].kind == InferenceMessageKind.CONTENT_TOKEN assert messages[0].token == "hi" assert messages[1].kind == InferenceMessageKind.DONE diff --git a/paddler_client_python/tests/test_inference_message.py b/paddler_client_python/tests/test_inference_message.py index 10ae28b3..26eeb506 100644 --- a/paddler_client_python/tests/test_inference_message.py +++ b/paddler_client_python/tests/test_inference_message.py @@ -6,27 +6,86 @@ ) -def test_parse_token_response() -> None: +def test_parse_content_token_response() -> None: data = { "Response": { "request_id": "req-1", - "response": {"GeneratedToken": {"Token": "hello"}}, + "response": {"GeneratedToken": {"ContentToken": "hello"}}, } } message = parse_inference_client_message(data) assert message.request_id == "req-1" - assert message.kind == InferenceMessageKind.TOKEN + assert message.kind == InferenceMessageKind.CONTENT_TOKEN assert message.token == "hello" assert message.is_token assert not message.is_terminal -def test_parse_done_response() -> None: +def test_parse_reasoning_token_response() -> None: data = { "Response": { "request_id": "req-1", - "response": {"GeneratedToken": "Done"}, + "response": {"GeneratedToken": {"ReasoningToken": "thinking"}}, + } + } + message = parse_inference_client_message(data) + + assert message.kind == InferenceMessageKind.REASONING_TOKEN + assert message.token == "thinking" + assert message.is_token + assert not message.is_terminal + + +def test_parse_tool_call_token_response() -> None: + data = { + "Response": { + "request_id": "req-1", + "response": {"GeneratedToken": {"ToolCallToken": "{\"name\":"}}, + } + } + message = parse_inference_client_message(data) + + assert message.kind == InferenceMessageKind.TOOL_CALL_TOKEN + assert message.token == "{\"name\":" + assert message.is_token + assert not message.is_terminal + + +def test_parse_undeterminable_token_response() -> None: + data = { + "Response": { + "request_id": "req-1", + "response": {"GeneratedToken": {"UndeterminableToken": "raw"}}, + } + } + message = parse_inference_client_message(data) + + assert message.kind == InferenceMessageKind.UNDETERMINABLE_TOKEN + assert message.token == "raw" + assert message.is_token + + +def test_parse_done_response_carries_summary() -> None: + data = { + "Response": { + "request_id": "req-1", + "response": { + "GeneratedToken": { + "Done": { + "usage": { + "prompt_tokens": 4, + "cached_prompt_tokens": 0, + "input_image_tokens": 0, + "input_audio_tokens": 0, + "content_tokens": 6, + "reasoning_tokens": 1, + "tool_call_tokens": 0, + "undeterminable_tokens": 0, + } + } + } + }, } } message = parse_inference_client_message(data) @@ -34,6 +93,12 @@ def test_parse_done_response() -> None: assert message.kind == InferenceMessageKind.DONE assert message.is_done assert message.is_terminal + assert message.summary is not None + assert message.summary.usage.prompt_tokens == 4 + assert message.summary.usage.content_tokens == 6 + assert message.summary.usage.reasoning_tokens == 1 + assert message.summary.usage.completion_tokens == 7 + assert message.summary.usage.total_tokens == 11 def test_parse_timeout() -> None: @@ -257,7 +322,10 @@ def test_parse_embedding_error() -> None: def test_parse_json_string() -> None: json_str = ( - '{"Response": {"request_id": "req-1", "response": {"GeneratedToken": "Done"}}}' + '{"Response": {"request_id": "req-1", "response": {"GeneratedToken": ' + '{"Done": {"usage": {"prompt_tokens": 0, "cached_prompt_tokens": 0, ' + '"input_image_tokens": 0, "input_audio_tokens": 0, "content_tokens": 0, ' + '"reasoning_tokens": 0, "tool_call_tokens": 0, "undeterminable_tokens": 0}}}}}}' ) message = parse_inference_client_message(json_str) @@ -310,6 +378,18 @@ def test_parse_unknown_generated_token_result_raises() -> None: parse_inference_client_message(data) +def test_parse_string_generated_token_result_raises() -> None: + data = { + "Response": { + "request_id": "req-1", + "response": {"GeneratedToken": "Done"}, + } + } + + with pytest.raises(ValueError, match="Unknown GeneratedTokenResult"): + parse_inference_client_message(data) + + def test_parse_unknown_embedding_result_raises() -> None: data = { "Response": { diff --git a/paddler_client_python/tests/test_integration_inference.py b/paddler_client_python/tests/test_integration_inference.py index 3293f083..ccb03a69 100644 --- a/paddler_client_python/tests/test_integration_inference.py +++ b/paddler_client_python/tests/test_integration_inference.py @@ -60,7 +60,7 @@ async def test_http_continue_from_conversation_history( ): _assert_not_error(message) - if message.kind == InferenceMessageKind.TOKEN: + if message.kind == InferenceMessageKind.CONTENT_TOKEN: assert message.token is not None tokens.append(message.token) elif message.is_terminal: @@ -89,7 +89,7 @@ async def test_websocket_continue_from_conversation_history( async for message in stream: _assert_not_error(message) - if message.kind == InferenceMessageKind.TOKEN: + if message.kind == InferenceMessageKind.CONTENT_TOKEN: assert message.token is not None tokens.append(message.token) elif message.is_terminal: @@ -114,7 +114,7 @@ async def test_websocket_continue_from_raw_prompt( async for message in stream: _assert_not_error(message) - if message.kind == InferenceMessageKind.TOKEN: + if message.kind == InferenceMessageKind.CONTENT_TOKEN: assert message.token is not None tokens.append(message.token) elif message.is_terminal: diff --git a/paddler_client_python/tests/test_response_stream.py b/paddler_client_python/tests/test_response_stream.py index ff0d7943..eb3fbbc2 100644 --- a/paddler_client_python/tests/test_response_stream.py +++ b/paddler_client_python/tests/test_response_stream.py @@ -11,7 +11,7 @@ def token_message() -> InferenceMessage: return InferenceMessage( request_id="req-1", - kind=InferenceMessageKind.TOKEN, + kind=InferenceMessageKind.CONTENT_TOKEN, token="hello", ) diff --git a/paddler_client_python/tests/test_stream_ndjson.py b/paddler_client_python/tests/test_stream_ndjson.py index f985c590..0d9fa036 100644 --- a/paddler_client_python/tests/test_stream_ndjson.py +++ b/paddler_client_python/tests/test_stream_ndjson.py @@ -13,7 +13,7 @@ def _make_token_line(request_id: str, token: str) -> str: { "Response": { "request_id": request_id, - "response": {"GeneratedToken": {"Token": token}}, + "response": {"GeneratedToken": {"ContentToken": token}}, } } ) @@ -24,7 +24,22 @@ def _make_done_line(request_id: str) -> str: { "Response": { "request_id": request_id, - "response": {"GeneratedToken": "Done"}, + "response": { + "GeneratedToken": { + "Done": { + "usage": { + "prompt_tokens": 0, + "cached_prompt_tokens": 0, + "input_image_tokens": 0, + "input_audio_tokens": 0, + "content_tokens": 0, + "reasoning_tokens": 0, + "tool_call_tokens": 0, + "undeterminable_tokens": 0, + } + } + } + }, } } ) @@ -48,7 +63,7 @@ def handler(request: httpx.Request) -> httpx.Response: messages.append(message) assert len(messages) == 2 - assert messages[0].kind == InferenceMessageKind.TOKEN + assert messages[0].kind == InferenceMessageKind.CONTENT_TOKEN assert messages[0].token == "hello" assert messages[1].kind == InferenceMessageKind.DONE From b52be86be3e10b3dd01a4ca128a2c4e12c92eb9d Mon Sep 17 00:00:00 2001 From: Mateusz Charytoniuk Date: Tue, 5 May 2026 14:58:00 +0200 Subject: [PATCH 09/51] Factor tool-call handling into a shared, single-responsibility pipeline MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New paddler::tool_call_* modules — one struct each, single responsibility: - ToolCallBuffer: append-only string buffer; pure data, fully unit-tested. - ToolCallParser: thin wrapper over Model::parse_chat_message; never deserialises JSON in Rust on model output. - ToolCallValidator: schema-driven where the tool declared one, JSON-object structural check otherwise; always invoked, with ValidatorBuildError surfacing schema-load failures and ToolCallValidationError separating UnknownToolName / InvalidJson / NotAnObject / SchemaMismatch. - ToolCallEvent: explicit event enum (Pending / Resolved / ParseFailed / ValidationFailed); pure data, unit-testable. - ToolCallPipeline: composes Buffer + Parser + Validator. Same component is shared by both endpoints; integration tests cover the end-to-end behaviour. Wire format gains three new GeneratedTokenResult variants — ToolCallParsed (structured, always emitted on close marker) plus ToolCallParseFailed and ToolCallValidationFailed (informational, do NOT terminate the request). paddler_types::ParsedToolCall is the shared wire value object. Scheduler integration: ContinuousBatchActiveRequest holds an Option; the scheduler feeds every ToolCallToken to the pipeline and finalises whenever the classifier transitions out of in_tool_call state, emitting the resulting structured event downstream. The pipeline is constructed only when the request actually has tools. OpenAI compat refactor: deleted parse_tool_call_payload, locate_json_object, and the ToolCallPayload struct — every JSON parse on model output is now gone. Both transformers consume the structured ToolCallParsed event and emit OpenAI-spec delta.tool_calls / message.tool_calls without ever peeking inside the model's raw text. Per-variant arms moved into focused helper methods (handle_content / handle_reasoning / handle_tool_call_parsed / handle_done) per single-responsibility audit. ParsedToolCall is added to paddler_types as a Serialize/Deserialize wire struct; bindings ships an internal twin for the FFI return. --- .../agent/continuous_batch_active_request.rs | 2 + .../src/agent/continuous_batch_scheduler.rs | 111 +++ .../prepare_conversation_history_request.rs | 2 + .../prepared_conversation_history_request.rs | 5 + .../http_route/post_chat_completions.rs | 722 ++++++++++-------- paddler/src/lib.rs | 7 + paddler/src/tool_call_buffer.rs | 123 +++ paddler/src/tool_call_event.rs | 85 +++ paddler/src/tool_call_parse_error.rs | 15 + paddler/src/tool_call_parser.rs | 78 ++ paddler/src/tool_call_pipeline.rs | 112 +++ paddler/src/tool_call_validation_error.rs | 26 + paddler/src/tool_call_validator.rs | 341 +++++++++ ...n3_classifier_detects_tool_call_markers.rs | 63 -- paddler_types/src/generated_token_result.rs | 51 ++ paddler_types/src/lib.rs | 1 + paddler_types/src/parsed_tool_call.rs | 64 ++ 17 files changed, 1407 insertions(+), 401 deletions(-) create mode 100644 paddler/src/tool_call_buffer.rs create mode 100644 paddler/src/tool_call_event.rs create mode 100644 paddler/src/tool_call_parse_error.rs create mode 100644 paddler/src/tool_call_parser.rs create mode 100644 paddler/src/tool_call_pipeline.rs create mode 100644 paddler/src/tool_call_validation_error.rs create mode 100644 paddler/src/tool_call_validator.rs delete mode 100644 paddler_tests/tests/qwen3_classifier_detects_tool_call_markers.rs create mode 100644 paddler_types/src/parsed_tool_call.rs diff --git a/paddler/src/agent/continuous_batch_active_request.rs b/paddler/src/agent/continuous_batch_active_request.rs index d3523ce1..bd561b10 100644 --- a/paddler/src/agent/continuous_batch_active_request.rs +++ b/paddler/src/agent/continuous_batch_active_request.rs @@ -8,6 +8,7 @@ use tokio::sync::mpsc; use tokio::sync::mpsc::error::TryRecvError; use crate::agent::continuous_batch_request_phase::ContinuousBatchRequestPhase; +use crate::tool_call_pipeline::ToolCallPipeline; pub struct ContinuousBatchActiveRequest { pub chain: LlamaSampler, @@ -23,6 +24,7 @@ pub struct ContinuousBatchActiveRequest { pub prompt_tokens: Vec, pub prompt_tokens_ingested: usize, pub sequence_id: i32, + pub tool_call_pipeline: Option, pub utf8_decoder: encoding_rs::Decoder, } diff --git a/paddler/src/agent/continuous_batch_scheduler.rs b/paddler/src/agent/continuous_batch_scheduler.rs index c042db5d..b282b542 100644 --- a/paddler/src/agent/continuous_batch_scheduler.rs +++ b/paddler/src/agent/continuous_batch_scheduler.rs @@ -23,6 +23,8 @@ use paddler_types::embedding_result::EmbeddingResult; use paddler_types::generated_token_result::GeneratedTokenResult; use paddler_types::generation_summary::GenerationSummary; use paddler_types::request_params::ContinueFromRawPromptParams; +use paddler_types::request_params::continue_from_conversation_history_params::tool::Tool; +use paddler_types::request_params::continue_from_conversation_history_params::tool::tool_params::function_call::parameters_schema::validated_parameters_schema::ValidatedParametersSchema; use rand::Rng as _; use rand::rngs::ThreadRng; use tokio::sync::mpsc; @@ -44,6 +46,9 @@ use crate::agent::sampling_outcome::SamplingOutcome; use crate::agent::sequence_id_pool::SequenceIdPool; use crate::agent::token_usage_from_bindings::token_usage_from_bindings; use crate::decoded_image::DecodedImage; +use crate::tool_call_parser::ToolCallParser; +use crate::tool_call_pipeline::ToolCallPipeline; +use crate::tool_call_validator::ToolCallValidator; use crate::dispenses_slots::DispensesSlots; use crate::slot_aggregated_status::SlotAggregatedStatus; @@ -206,11 +211,13 @@ impl ContinuousBatchScheduler { raw_prompt, max_tokens, grammar_sampler, + tools, } => { self.accept_text_prompt( &raw_prompt, max_tokens, grammar_sampler, + tools, generated_tokens_tx, generate_tokens_stop_rx, ); @@ -220,6 +227,7 @@ impl ContinuousBatchScheduler { images, max_tokens, grammar_sampler, + tools, } => { let multimodal_context = self.scheduler_context.multimodal_context.clone(); @@ -230,6 +238,7 @@ impl ContinuousBatchScheduler { &images, max_tokens, grammar_sampler, + tools, generated_tokens_tx, generate_tokens_stop_rx, ); @@ -267,6 +276,7 @@ impl ContinuousBatchScheduler { &raw_prompt, max_tokens, grammar_sampler, + Vec::new(), generated_tokens_tx, generate_tokens_stop_rx, ); @@ -327,11 +337,65 @@ impl ContinuousBatchScheduler { ) } + fn build_tool_call_pipeline( + &self, + tools: Vec>, + ) -> Option { + if tools.is_empty() { + return None; + } + + let validator = match ToolCallValidator::from_tools(&tools) { + Ok(validator) => validator, + Err(err) => { + error!( + "{:?}: failed to build tool-call validator (no schema validation): {err:#}", + self.scheduler_context.agent_name + ); + return None; + } + }; + + let tools_json: Vec = match tools + .into_iter() + .map(|tool| serde_json::to_value(&tool)) + .collect::, _>>() + { + Ok(values) => values, + Err(err) => { + error!( + "{:?}: failed to serialize tools for tool-call parser: {err}", + self.scheduler_context.agent_name + ); + return None; + } + }; + + let parser = match ToolCallParser::new(self.scheduler_context.model.clone(), &tools_json) + { + Ok(parser) => parser, + Err(err) => { + error!( + "{:?}: failed to construct tool-call parser: {err}", + self.scheduler_context.agent_name + ); + return None; + } + }; + + Some(ToolCallPipeline::new(parser, validator)) + } + + #[expect( + clippy::too_many_arguments, + reason = "text-prompt acceptance ties together prompt, sampler, classifier, tool-call pipeline, and the two channels" + )] fn accept_text_prompt( &mut self, prompt: &str, max_tokens: i32, grammar_sampler: Option, + tools: Vec>, generated_tokens_tx: mpsc::UnboundedSender, generate_tokens_stop_rx: mpsc::UnboundedReceiver<()>, ) { @@ -451,6 +515,8 @@ impl ContinuousBatchScheduler { prompt_tokens.len() ); + let tool_call_pipeline = self.build_tool_call_pipeline(tools); + self.active_requests.push(ContinuousBatchActiveRequest { chain, token_classifier, @@ -465,6 +531,7 @@ impl ContinuousBatchScheduler { prompt_tokens, prompt_tokens_ingested: 0, sequence_id, + tool_call_pipeline, utf8_decoder: encoding_rs::UTF_8.new_decoder(), }); } @@ -480,6 +547,7 @@ impl ContinuousBatchScheduler { images: &[DecodedImage], max_tokens: i32, grammar_sampler: Option, + tools: Vec>, generated_tokens_tx: mpsc::UnboundedSender, generate_tokens_stop_rx: mpsc::UnboundedReceiver<()>, ) { @@ -675,6 +743,8 @@ impl ContinuousBatchScheduler { self.scheduler_context.agent_name ); + let tool_call_pipeline = self.build_tool_call_pipeline(tools); + self.active_requests.push(ContinuousBatchActiveRequest { chain, token_classifier, @@ -689,6 +759,7 @@ impl ContinuousBatchScheduler { prompt_tokens: Vec::new(), prompt_tokens_ingested: 0, sequence_id, + tool_call_pipeline, utf8_decoder: encoding_rs::UTF_8.new_decoder(), }); } @@ -944,7 +1015,9 @@ impl ContinuousBatchScheduler { } }; + let was_in_tool_call = active_request.token_classifier.is_in_tool_call(); let sampled_token = active_request.token_classifier.ingest(raw_token); + let is_in_tool_call = active_request.token_classifier.is_in_tool_call(); if self.scheduler_context.model.is_eog_token(&sampled_token) { let summary = GenerationSummary { @@ -980,6 +1053,12 @@ impl ContinuousBatchScheduler { } }; + if matches!(sampled_token, SampledToken::ToolCall(_)) + && let Some(pipeline) = active_request.tool_call_pipeline.as_mut() + { + pipeline.feed(&output_string); + } + let token_event = match sampled_token { SampledToken::Content(_) => GeneratedTokenResult::ContentToken(output_string), SampledToken::Reasoning(_) => GeneratedTokenResult::ReasoningToken(output_string), @@ -1005,6 +1084,38 @@ impl ContinuousBatchScheduler { continue; } + if was_in_tool_call + && !is_in_tool_call + && let Some(pipeline) = active_request.tool_call_pipeline.as_mut() + { + let tool_call_event_message = match pipeline.finalize() { + crate::tool_call_event::ToolCallEvent::Resolved(parsed) => { + Some(GeneratedTokenResult::ToolCallParsed(parsed)) + } + crate::tool_call_event::ToolCallEvent::ParseFailed(err) => { + Some(GeneratedTokenResult::ToolCallParseFailed(err.to_string())) + } + crate::tool_call_event::ToolCallEvent::ValidationFailed(errors) => { + Some(GeneratedTokenResult::ToolCallValidationFailed( + errors.into_iter().map(|err| err.to_string()).collect(), + )) + } + crate::tool_call_event::ToolCallEvent::Pending => None, + }; + + if let Some(event) = tool_call_event_message + && active_request.generated_tokens_tx.send(event).is_err() + { + warn!( + "{:?}: sequence {} client disconnected (receiver dropped)", + self.scheduler_context.agent_name, active_request.sequence_id + ); + active_request.i_batch = None; + active_request.phase = ContinuousBatchRequestPhase::Completed; + continue; + } + } + let usage = active_request.token_classifier.usage(); let completion_so_far = usage.content_tokens() + usage.reasoning_tokens() diff --git a/paddler/src/agent/prepare_conversation_history_request.rs b/paddler/src/agent/prepare_conversation_history_request.rs index ac49edb1..8dad29df 100644 --- a/paddler/src/agent/prepare_conversation_history_request.rs +++ b/paddler/src/agent/prepare_conversation_history_request.rs @@ -129,6 +129,7 @@ pub fn prepare_conversation_history_request( images, max_tokens, grammar_sampler, + tools, }); } @@ -136,5 +137,6 @@ pub fn prepare_conversation_history_request( raw_prompt, max_tokens, grammar_sampler, + tools, }) } diff --git a/paddler/src/agent/prepared_conversation_history_request.rs b/paddler/src/agent/prepared_conversation_history_request.rs index 5485feb3..dc4f2ae3 100644 --- a/paddler/src/agent/prepared_conversation_history_request.rs +++ b/paddler/src/agent/prepared_conversation_history_request.rs @@ -1,3 +1,6 @@ +use paddler_types::request_params::continue_from_conversation_history_params::tool::Tool; +use paddler_types::request_params::continue_from_conversation_history_params::tool::tool_params::function_call::parameters_schema::validated_parameters_schema::ValidatedParametersSchema; + use crate::agent::grammar_sampler::GrammarSampler; use crate::decoded_image::DecodedImage; @@ -6,11 +9,13 @@ pub enum PreparedConversationHistoryRequest { raw_prompt: String, max_tokens: i32, grammar_sampler: Option, + tools: Vec>, }, MultimodalPrompt { raw_prompt: String, images: Vec, max_tokens: i32, grammar_sampler: Option, + tools: Vec>, }, } diff --git a/paddler/src/balancer/compatibility/openai_service/http_route/post_chat_completions.rs b/paddler/src/balancer/compatibility/openai_service/http_route/post_chat_completions.rs index b78cee9c..13564d88 100644 --- a/paddler/src/balancer/compatibility/openai_service/http_route/post_chat_completions.rs +++ b/paddler/src/balancer/compatibility/openai_service/http_route/post_chat_completions.rs @@ -20,6 +20,7 @@ use paddler_types::inference_client::Message as OutgoingMessage; use paddler_types::inference_client::Response as OutgoingResponse; use paddler_types::jsonrpc::ErrorEnvelope; use paddler_types::jsonrpc::ResponseEnvelope; +use paddler_types::parsed_tool_call::ParsedToolCall; use paddler_types::request_params::ContinueFromConversationHistoryParams; use paddler_types::request_params::continue_from_conversation_history_params::tool::Tool; use paddler_types::request_params::continue_from_conversation_history_params::tool::tool_params::function_call::parameters_schema::raw_parameters_schema::RawParametersSchema; @@ -77,6 +78,17 @@ fn current_timestamp() -> u64 { .as_secs() } +fn validation_failure_message(errors: &[String]) -> String { + errors + .first() + .cloned() + .unwrap_or_else(|| "tool call failed validation".to_owned()) +} + +fn server_error_chunk(description: &str) -> TransformResult { + TransformResult::Error(openai_error_json("server_error", description).to_string()) +} + #[derive(Deserialize)] struct OpenAIMessage { content: ConversationMessageContent, @@ -111,18 +123,9 @@ struct OpenAICompletionRequestParams { tools: Vec>, } +#[derive(Default)] struct OpenAIStreamingState { saw_tool_call: bool, - tool_call_id: String, -} - -impl OpenAIStreamingState { - fn new() -> Self { - Self { - saw_tool_call: false, - tool_call_id: format!("call_{}", nanoid!()), - } - } } #[derive(Clone)] @@ -176,12 +179,27 @@ impl OpenAIStreamingResponseTransformer { }))?) } - fn tool_call_arguments_chunk( + fn tool_calls_chunk( &self, request_id: &str, - text: &str, - tool_call_id: &str, + parsed_calls: &[ParsedToolCall], ) -> Result { + let tool_calls: Vec = parsed_calls + .iter() + .enumerate() + .map(|(index, call)| { + json!({ + "index": index, + "id": call.id, + "type": "function", + "function": { + "name": call.name, + "arguments": call.arguments_json, + } + }) + }) + .collect(); + Ok(serde_json::to_string(&json!({ "id": request_id, "object": "chat.completion.chunk", @@ -193,16 +211,7 @@ impl OpenAIStreamingResponseTransformer { "index": 0, "delta": { "role": "assistant", - "tool_calls": [ - { - "index": 0, - "id": tool_call_id, - "type": "function", - "function": { - "arguments": text, - } - } - ], + "tool_calls": tool_calls, }, "logprobs": null, "finish_reason": null @@ -240,6 +249,59 @@ impl OpenAIStreamingResponseTransformer { "usage": openai_usage_json(usage), }))?) } + + fn handle_content(&self, request_id: &str, text: &str) -> Result> { + Ok(vec![TransformResult::Chunk( + self.content_chunk(request_id, text)?, + )]) + } + + fn handle_reasoning(&self, request_id: &str, text: &str) -> Result> { + Ok(vec![TransformResult::Chunk( + self.reasoning_chunk(request_id, text)?, + )]) + } + + fn handle_tool_call_parsed( + &self, + request_id: &str, + parsed_calls: &[ParsedToolCall], + ) -> Result> { + if parsed_calls.is_empty() { + return Ok(vec![]); + } + + self.state + .lock() + .map_err(|err| anyhow!("streaming state mutex poisoned: {err}"))? + .saw_tool_call = true; + + Ok(vec![TransformResult::Chunk( + self.tool_calls_chunk(request_id, parsed_calls)?, + )]) + } + + fn handle_done( + &self, + request_id: &str, + summary: &GenerationSummary, + ) -> Result> { + let saw_tool_call = self + .state + .lock() + .map_err(|err| anyhow!("streaming state mutex poisoned: {err}"))? + .saw_tool_call; + + let finish_reason = if saw_tool_call { "tool_calls" } else { "stop" }; + let finish = TransformResult::Chunk(self.finish_chunk(request_id, finish_reason)?); + + if self.include_usage { + let usage = TransformResult::Chunk(self.usage_chunk(request_id, &summary.usage)?); + Ok(vec![finish, usage]) + } else { + Ok(vec![finish]) + } + } } #[async_trait] @@ -253,56 +315,38 @@ impl TransformsOutgoingMessage for OpenAIStreamingResponseTransformer { GeneratedTokenResult::ContentToken(text) | GeneratedTokenResult::UndeterminableToken(text), ), - }) => Ok(vec![TransformResult::Chunk( - self.content_chunk(&request_id, &text)?, - )]), + }) => self.handle_content(&request_id, &text), OutgoingMessage::Response(ResponseEnvelope { request_id, response: OutgoingResponse::GeneratedToken(GeneratedTokenResult::ReasoningToken(text)), - }) => Ok(vec![TransformResult::Chunk( - self.reasoning_chunk(&request_id, &text)?, - )]), + }) => self.handle_reasoning(&request_id, &text), + OutgoingMessage::Response(ResponseEnvelope { + response: OutgoingResponse::GeneratedToken(GeneratedTokenResult::ToolCallToken(_)), + .. + }) => Ok(vec![]), OutgoingMessage::Response(ResponseEnvelope { request_id, - response: OutgoingResponse::GeneratedToken(GeneratedTokenResult::ToolCallToken(text)), - }) => { - let tool_call_id = { - let mut state = self - .state - .lock() - .map_err(|err| anyhow!("streaming state mutex poisoned: {err}"))?; - state.saw_tool_call = true; - state.tool_call_id.clone() - }; - - Ok(vec![TransformResult::Chunk( - self.tool_call_arguments_chunk(&request_id, &text, &tool_call_id)?, - )]) - } + response: + OutgoingResponse::GeneratedToken(GeneratedTokenResult::ToolCallParsed(parsed_calls)), + }) => self.handle_tool_call_parsed(&request_id, &parsed_calls), + OutgoingMessage::Response(ResponseEnvelope { + response: + OutgoingResponse::GeneratedToken(GeneratedTokenResult::ToolCallParseFailed( + description, + )), + .. + }) => Ok(vec![server_error_chunk(&description)]), + OutgoingMessage::Response(ResponseEnvelope { + response: + OutgoingResponse::GeneratedToken(GeneratedTokenResult::ToolCallValidationFailed( + errors, + )), + .. + }) => Ok(vec![server_error_chunk(&validation_failure_message(&errors))]), OutgoingMessage::Response(ResponseEnvelope { request_id, response: OutgoingResponse::GeneratedToken(GeneratedTokenResult::Done(summary)), - }) => { - let saw_tool_call = self - .state - .lock() - .map_err(|err| anyhow!("streaming state mutex poisoned: {err}"))? - .saw_tool_call; - - let finish_reason = if saw_tool_call { "tool_calls" } else { "stop" }; - let finish = - TransformResult::Chunk(self.finish_chunk(&request_id, finish_reason)?); - - if self.include_usage { - let usage = TransformResult::Chunk( - self.usage_chunk(&request_id, &summary.usage)?, - ); - - Ok(vec![finish, usage]) - } else { - Ok(vec![finish]) - } - } + }) => self.handle_done(&request_id, &summary), OutgoingMessage::Response(ResponseEnvelope { response: OutgoingResponse::GeneratedToken( @@ -320,9 +364,7 @@ impl TransformsOutgoingMessage for OpenAIStreamingResponseTransformer { | OutgoingMessage::Error(ErrorEnvelope { error: paddler_types::jsonrpc::Error { description, .. }, .. - }) => Ok(vec![TransformResult::Error( - openai_error_json("server_error", &description).to_string(), - )]), + }) => Ok(vec![server_error_chunk(&description)]), OutgoingMessage::Response(ResponseEnvelope { response: OutgoingResponse::Timeout, .. @@ -349,62 +391,11 @@ impl TransformsOutgoingMessage for OpenAIStreamingResponseTransformer { } } -struct ToolCallPayload { - name: String, - arguments: String, -} - -fn parse_tool_call_payload(buffer: &str) -> ToolCallPayload { - let trimmed = buffer.trim(); - let json_slice = locate_json_object(trimmed); - - let Ok(parsed) = serde_json::from_str::(json_slice) else { - return ToolCallPayload { - name: String::new(), - arguments: trimmed.to_owned(), - }; - }; - - let name = parsed - .get("name") - .and_then(serde_json::Value::as_str) - .unwrap_or_default() - .to_owned(); - - let arguments = parsed - .get("arguments") - .map_or_else(String::new, |value| { - serde_json::to_string(value).unwrap_or_default() - }); - - ToolCallPayload { name, arguments } -} - -/// Returns the substring from the first `{` to the last `}` (inclusive) when -/// both are present. The model's tool-call output is a JSON object wrapped by -/// special section tokens, so locating the object by its structural braces -/// matches what llama.cpp's autoparser does and avoids relying on the exact -/// marker text emitted alongside. -fn locate_json_object(buffer: &str) -> &str { - let Some(start) = buffer.find('{') else { - return buffer; - }; - let Some(end) = buffer.rfind('}') else { - return buffer; - }; - if end < start { - return buffer; - } - - &buffer[start..=end] -} - #[derive(Default)] struct OpenAINonStreamingState { content: String, reasoning: String, - tool_call: String, - summary: Option, + tool_calls: Vec, } #[derive(Clone)] @@ -413,6 +404,103 @@ struct OpenAINonStreamingResponseTransformer { state: Arc>, } +impl OpenAINonStreamingResponseTransformer { + fn append_content(&self, text: &str) -> Result<()> { + let mut state = self + .state + .lock() + .map_err(|err| anyhow!("non-streaming state mutex poisoned: {err}"))?; + state.content.push_str(text); + Ok(()) + } + + fn append_reasoning(&self, text: &str) -> Result<()> { + let mut state = self + .state + .lock() + .map_err(|err| anyhow!("non-streaming state mutex poisoned: {err}"))?; + state.reasoning.push_str(text); + Ok(()) + } + + fn append_tool_calls(&self, parsed_calls: Vec) -> Result<()> { + let mut state = self + .state + .lock() + .map_err(|err| anyhow!("non-streaming state mutex poisoned: {err}"))?; + state.tool_calls.extend(parsed_calls); + Ok(()) + } + + fn build_done_chunk( + &self, + request_id: &str, + summary: &GenerationSummary, + ) -> Result { + let state = self + .state + .lock() + .map_err(|err| anyhow!("non-streaming state mutex poisoned: {err}"))?; + + let has_tool_calls = !state.tool_calls.is_empty(); + let finish_reason = if has_tool_calls { "tool_calls" } else { "stop" }; + + let mut message_obj = json!({ + "role": "assistant", + "content": if state.content.is_empty() && has_tool_calls { + serde_json::Value::Null + } else { + json!(state.content) + }, + "refusal": null, + "annotations": [] + }); + + if !state.reasoning.is_empty() + && let Some(map) = message_obj.as_object_mut() + { + map.insert("reasoning_content".to_owned(), json!(state.reasoning)); + } + + if has_tool_calls + && let Some(map) = message_obj.as_object_mut() + { + let tool_calls_json: Vec = state + .tool_calls + .iter() + .map(|call| { + json!({ + "id": call.id, + "type": "function", + "function": { + "name": call.name, + "arguments": call.arguments_json, + } + }) + }) + .collect(); + map.insert("tool_calls".to_owned(), json!(tool_calls_json)); + } + + Ok(serde_json::to_string(&json!({ + "id": request_id, + "object": "chat.completion", + "created": current_timestamp(), + "model": self.model, + "choices": [ + { + "index": 0, + "message": message_obj, + "logprobs": null, + "finish_reason": finish_reason + } + ], + "usage": openai_usage_json(&summary.usage), + "service_tier": "default" + }))?) + } +} + #[async_trait] impl TransformsOutgoingMessage for OpenAINonStreamingResponseTransformer { async fn transform(&self, message: OutgoingMessage) -> Result> { @@ -425,117 +513,48 @@ impl TransformsOutgoingMessage for OpenAINonStreamingResponseTransformer { ), .. }) => { - { - let mut state = self - .state - .lock() - .map_err(|err| anyhow!("non-streaming state mutex poisoned: {err}"))?; - state.content.push_str(&text); - } - + self.append_content(&text)?; Ok(vec![]) } OutgoingMessage::Response(ResponseEnvelope { response: OutgoingResponse::GeneratedToken(GeneratedTokenResult::ReasoningToken(text)), .. }) => { - { - let mut state = self - .state - .lock() - .map_err(|err| anyhow!("non-streaming state mutex poisoned: {err}"))?; - state.reasoning.push_str(&text); - } - + self.append_reasoning(&text)?; Ok(vec![]) } OutgoingMessage::Response(ResponseEnvelope { - response: OutgoingResponse::GeneratedToken(GeneratedTokenResult::ToolCallToken(text)), + response: OutgoingResponse::GeneratedToken(GeneratedTokenResult::ToolCallToken(_)), + .. + }) => Ok(vec![]), + OutgoingMessage::Response(ResponseEnvelope { + response: + OutgoingResponse::GeneratedToken(GeneratedTokenResult::ToolCallParsed(parsed_calls)), .. }) => { - { - let mut state = self - .state - .lock() - .map_err(|err| anyhow!("non-streaming state mutex poisoned: {err}"))?; - state.tool_call.push_str(&text); - } - + self.append_tool_calls(parsed_calls)?; Ok(vec![]) } + OutgoingMessage::Response(ResponseEnvelope { + response: + OutgoingResponse::GeneratedToken(GeneratedTokenResult::ToolCallParseFailed( + description, + )), + .. + }) => Ok(vec![server_error_chunk(&description)]), + OutgoingMessage::Response(ResponseEnvelope { + response: + OutgoingResponse::GeneratedToken(GeneratedTokenResult::ToolCallValidationFailed( + errors, + )), + .. + }) => Ok(vec![server_error_chunk(&validation_failure_message(&errors))]), OutgoingMessage::Response(ResponseEnvelope { request_id, response: OutgoingResponse::GeneratedToken(GeneratedTokenResult::Done(summary)), - }) => { - let mut state = self - .state - .lock() - .map_err(|err| anyhow!("non-streaming state mutex poisoned: {err}"))?; - - state.summary = Some(summary); - - let mut message_obj = json!({ - "role": "assistant", - "content": state.content, - "refusal": null, - "annotations": [] - }); - - if !state.reasoning.is_empty() - && let Some(map) = message_obj.as_object_mut() - { - map.insert("reasoning_content".to_owned(), json!(state.reasoning)); - } - - let finish_reason = if state.tool_call.is_empty() { - "stop" - } else { - let parsed_tool_call = parse_tool_call_payload(&state.tool_call); - - if let Some(map) = message_obj.as_object_mut() { - map.insert( - "content".to_owned(), - if state.content.is_empty() { - serde_json::Value::Null - } else { - json!(state.content) - }, - ); - map.insert( - "tool_calls".to_owned(), - json!([{ - "id": format!("call_{}", nanoid!()), - "type": "function", - "function": { - "name": parsed_tool_call.name, - "arguments": parsed_tool_call.arguments, - } - }]), - ); - } - - "tool_calls" - }; - - let body = serde_json::to_string(&json!({ - "id": request_id, - "object": "chat.completion", - "created": current_timestamp(), - "model": self.model, - "choices": [ - { - "index": 0, - "message": message_obj, - "logprobs": null, - "finish_reason": finish_reason - } - ], - "usage": openai_usage_json(&summary.usage), - "service_tier": "default" - }))?; - - Ok(vec![TransformResult::Chunk(body)]) - } + }) => Ok(vec![TransformResult::Chunk( + self.build_done_chunk(&request_id, &summary)?, + )]), OutgoingMessage::Response(ResponseEnvelope { response: OutgoingResponse::GeneratedToken( @@ -553,9 +572,7 @@ impl TransformsOutgoingMessage for OpenAINonStreamingResponseTransformer { | OutgoingMessage::Error(ErrorEnvelope { error: paddler_types::jsonrpc::Error { description, .. }, .. - }) => Ok(vec![TransformResult::Error( - openai_error_json("server_error", &description).to_string(), - )]), + }) => Ok(vec![server_error_chunk(&description)]), OutgoingMessage::Response(ResponseEnvelope { response: OutgoingResponse::Timeout, .. @@ -633,7 +650,7 @@ async fn respond( OpenAIStreamingResponseTransformer { include_usage, model: openai_params.model.clone(), - state: Arc::new(Mutex::new(OpenAIStreamingState::new())), + state: Arc::new(Mutex::new(OpenAIStreamingState::default())), system_fingerprint: nanoid!(), }, )) @@ -695,6 +712,7 @@ mod tests { use paddler_types::inference_client::Response as OutgoingResponse; use paddler_types::jsonrpc::ErrorEnvelope; use paddler_types::jsonrpc::ResponseEnvelope; + use paddler_types::parsed_tool_call::ParsedToolCall; use paddler_types::token_usage::TokenUsage; use super::OpenAINonStreamingResponseTransformer; @@ -731,7 +749,7 @@ mod tests { OpenAIStreamingResponseTransformer { include_usage, model: "test-model".to_owned(), - state: Arc::new(Mutex::new(super::OpenAIStreamingState::new())), + state: Arc::new(Mutex::new(super::OpenAIStreamingState::default())), system_fingerprint: "test-fingerprint".to_owned(), } } @@ -797,6 +815,14 @@ mod tests { } } + fn weather_call() -> ParsedToolCall { + ParsedToolCall::new( + "call_x".to_owned(), + "get_weather".to_owned(), + "{\"location\":\"Paris\"}".to_owned(), + ) + } + #[actix_web::test] async fn streaming_content_token_emits_content_delta() -> Result<()> { let transformer = streaming_transformer(false); @@ -843,17 +869,38 @@ mod tests { } #[actix_web::test] - async fn streaming_done_without_include_usage_emits_only_finish_chunk() -> Result<()> { + async fn streaming_tool_call_token_is_silently_dropped() -> Result<()> { let transformer = streaming_transformer(false); - let summary = summary_with_counts(5, 3, 2); let chunks = transformer - .transform(make_token_message(GeneratedTokenResult::Done(summary))) + .transform(make_token_message(GeneratedTokenResult::ToolCallToken( + "{".to_owned(), + ))) + .await?; + + assert_eq!(chunks.len(), 0); + + Ok(()) + } + + #[actix_web::test] + async fn streaming_tool_call_parsed_emits_structured_tool_calls_chunk() -> Result<()> { + let transformer = streaming_transformer(false); + + let chunks = transformer + .transform(make_token_message(GeneratedTokenResult::ToolCallParsed(vec![ + weather_call(), + ]))) .await?; assert_eq!(chunks.len(), 1); - assert_chunk_contains(&chunks[0], "\"finish_reason\":\"stop\"")?; - assert_chunk_does_not_contain(&chunks[0], "usage")?; + assert_chunk_contains(&chunks[0], "\"tool_calls\"")?; + assert_chunk_contains(&chunks[0], "\"id\":\"call_x\"")?; + assert_chunk_contains(&chunks[0], "\"name\":\"get_weather\"")?; + assert_chunk_contains( + &chunks[0], + "\"arguments\":\"{\\\"location\\\":\\\"Paris\\\"}\"", + )?; Ok(()) } @@ -863,9 +910,9 @@ mod tests { let transformer = streaming_transformer(false); transformer - .transform(make_token_message(GeneratedTokenResult::ToolCallToken( - "{\"name\":\"x\"}".to_owned(), - ))) + .transform(make_token_message(GeneratedTokenResult::ToolCallParsed(vec![ + weather_call(), + ]))) .await?; let summary = summary_with_counts(2, 0, 0); @@ -914,7 +961,6 @@ mod tests { assert_chunk_does_not_contain(&chunks[0], "usage")?; assert_chunk_contains(&chunks[1], "\"prompt_tokens\":7")?; assert_chunk_contains(&chunks[1], "\"completion_tokens\":5")?; - assert_chunk_contains(&chunks[1], "\"reasoning_tokens\":1")?; assert_chunk_contains(&chunks[1], "\"total_tokens\":12")?; assert_chunk_contains(&chunks[1], "\"choices\":[]")?; @@ -922,118 +968,54 @@ mod tests { } #[actix_web::test] - async fn streaming_tool_call_token_emits_tool_calls_arguments_delta() -> Result<()> { + async fn streaming_done_without_include_usage_emits_only_finish_chunk() -> Result<()> { let transformer = streaming_transformer(false); + let summary = summary_with_counts(5, 3, 2); - let message = make_token_message(GeneratedTokenResult::ToolCallToken( - "{\"name\":\"get_weather\"}".to_owned(), - )); - let chunks = transformer.transform(message).await?; + let chunks = transformer + .transform(make_token_message(GeneratedTokenResult::Done(summary))) + .await?; assert_eq!(chunks.len(), 1); - assert_chunk_contains(&chunks[0], "\"tool_calls\"")?; - assert_chunk_contains(&chunks[0], "\"function\"")?; - assert_chunk_contains(&chunks[0], "\"arguments\":\"{\\\"name\\\":\\\"get_weather\\\"}\"")?; - assert_chunk_does_not_contain(&chunks[0], "\"content\":")?; - assert_chunk_does_not_contain(&chunks[0], "reasoning_content")?; + assert_chunk_contains(&chunks[0], "\"finish_reason\":\"stop\"")?; + assert_chunk_does_not_contain(&chunks[0], "usage")?; Ok(()) } #[actix_web::test] - async fn non_streaming_tool_call_aggregates_into_message_tool_calls() -> Result<()> { - let transformer = non_streaming_transformer(); + async fn streaming_tool_call_parse_failed_emits_server_error() -> Result<()> { + let transformer = streaming_transformer(false); - transformer - .transform(make_token_message(GeneratedTokenResult::ToolCallToken( - "{\"name\":\"get_weather\",\"arguments\":{\"location\":\"Paris\"}}".to_owned(), + let chunks = transformer + .transform(make_token_message(GeneratedTokenResult::ToolCallParseFailed( + "bad payload".to_owned(), ))) .await?; - let summary = summary_with_counts(4, 0, 0); - let final_chunks = transformer - .transform(make_token_message(GeneratedTokenResult::Done(summary))) - .await?; - - assert_eq!(final_chunks.len(), 1); - assert_chunk_contains(&final_chunks[0], "\"tool_calls\":")?; - assert_chunk_contains(&final_chunks[0], "\"name\":\"get_weather\"")?; - assert_chunk_contains( - &final_chunks[0], - "\"arguments\":\"{\\\"location\\\":\\\"Paris\\\"}\"", - )?; - assert_chunk_contains(&final_chunks[0], "\"finish_reason\":\"tool_calls\"")?; + assert_eq!(chunks.len(), 1); + assert_error_contains(&chunks[0], "bad payload")?; + assert_error_contains(&chunks[0], "server_error")?; Ok(()) } #[actix_web::test] - async fn non_streaming_unparseable_tool_call_falls_back_to_raw_arguments() -> Result<()> { - let transformer = non_streaming_transformer(); - - transformer - .transform(make_token_message(GeneratedTokenResult::ToolCallToken( - "garbage payload".to_owned(), - ))) - .await?; + async fn streaming_tool_call_validation_failed_emits_server_error() -> Result<()> { + let transformer = streaming_transformer(false); - let summary = summary_with_counts(2, 0, 0); - let final_chunks = transformer - .transform(make_token_message(GeneratedTokenResult::Done(summary))) + let chunks = transformer + .transform(make_token_message( + GeneratedTokenResult::ToolCallValidationFailed(vec!["missing field x".to_owned()]), + )) .await?; - assert_eq!(final_chunks.len(), 1); - assert_chunk_contains(&final_chunks[0], "\"tool_calls\":")?; - assert_chunk_contains(&final_chunks[0], "\"arguments\":\"garbage payload\"")?; + assert_eq!(chunks.len(), 1); + assert_error_contains(&chunks[0], "missing field x")?; Ok(()) } - #[test] - fn parse_tool_call_payload_extracts_name_and_arguments() { - let parsed = super::parse_tool_call_payload( - "{\"name\":\"get_weather\",\"arguments\":{\"location\":\"Paris\",\"unit\":\"c\"}}", - ); - - assert_eq!(parsed.name, "get_weather"); - assert_eq!(parsed.arguments, "{\"location\":\"Paris\",\"unit\":\"c\"}"); - } - - #[test] - fn parse_tool_call_payload_handles_whitespace_around_payload() { - let parsed = - super::parse_tool_call_payload("\n {\"name\":\"x\",\"arguments\":{}} \n"); - - assert_eq!(parsed.name, "x"); - assert_eq!(parsed.arguments, "{}"); - } - - #[test] - fn parse_tool_call_payload_returns_raw_arguments_when_invalid_json() { - let parsed = super::parse_tool_call_payload("not even close to JSON"); - - assert_eq!(parsed.name, ""); - assert_eq!(parsed.arguments, "not even close to JSON"); - } - - #[test] - fn parse_tool_call_payload_with_missing_arguments_returns_empty_string() { - let parsed = super::parse_tool_call_payload("{\"name\":\"x\"}"); - - assert_eq!(parsed.name, "x"); - assert_eq!(parsed.arguments, ""); - } - - #[test] - fn parse_tool_call_payload_strips_marker_tokens_around_json() { - let parsed = super::parse_tool_call_payload( - "\n{\"name\":\"get_weather\",\"arguments\":{\"location\":\"Paris\"}}\n", - ); - - assert_eq!(parsed.name, "get_weather"); - assert_eq!(parsed.arguments, "{\"location\":\"Paris\"}"); - } - #[actix_web::test] async fn streaming_error_message_returns_error_variant() -> Result<()> { let transformer = streaming_transformer(false); @@ -1128,24 +1110,16 @@ mod tests { async fn non_streaming_aggregates_content_only_when_no_reasoning() -> Result<()> { let transformer = non_streaming_transformer(); - assert_eq!( - transformer - .transform(make_token_message(GeneratedTokenResult::ContentToken( - "hel".to_owned() - ))) - .await? - .len(), - 0 - ); - assert_eq!( - transformer - .transform(make_token_message(GeneratedTokenResult::ContentToken( - "lo".to_owned() - ))) - .await? - .len(), - 0 - ); + transformer + .transform(make_token_message(GeneratedTokenResult::ContentToken( + "hel".to_owned(), + ))) + .await?; + transformer + .transform(make_token_message(GeneratedTokenResult::ContentToken( + "lo".to_owned(), + ))) + .await?; let summary = summary_with_counts(4, 2, 0); let final_chunks = transformer @@ -1211,6 +1185,65 @@ mod tests { Ok(()) } + #[actix_web::test] + async fn non_streaming_tool_call_parsed_populates_message_tool_calls() -> Result<()> { + let transformer = non_streaming_transformer(); + + transformer + .transform(make_token_message(GeneratedTokenResult::ToolCallParsed(vec![ + weather_call(), + ]))) + .await?; + + let summary = summary_with_counts(4, 0, 0); + let final_chunks = transformer + .transform(make_token_message(GeneratedTokenResult::Done(summary))) + .await?; + + assert_eq!(final_chunks.len(), 1); + assert_chunk_contains(&final_chunks[0], "\"tool_calls\":")?; + assert_chunk_contains(&final_chunks[0], "\"name\":\"get_weather\"")?; + assert_chunk_contains( + &final_chunks[0], + "\"arguments\":\"{\\\"location\\\":\\\"Paris\\\"}\"", + )?; + assert_chunk_contains(&final_chunks[0], "\"finish_reason\":\"tool_calls\"")?; + + Ok(()) + } + + #[actix_web::test] + async fn non_streaming_tool_call_parse_failed_emits_error() -> Result<()> { + let transformer = non_streaming_transformer(); + + let chunks = transformer + .transform(make_token_message(GeneratedTokenResult::ToolCallParseFailed( + "bad payload".to_owned(), + ))) + .await?; + + assert_eq!(chunks.len(), 1); + assert_error_contains(&chunks[0], "bad payload")?; + + Ok(()) + } + + #[actix_web::test] + async fn non_streaming_tool_call_validation_failed_emits_error() -> Result<()> { + let transformer = non_streaming_transformer(); + + let chunks = transformer + .transform(make_token_message( + GeneratedTokenResult::ToolCallValidationFailed(vec!["bad shape".to_owned()]), + )) + .await?; + + assert_eq!(chunks.len(), 1); + assert_error_contains(&chunks[0], "bad shape")?; + + Ok(()) + } + #[actix_web::test] async fn non_streaming_error_message_returns_error_variant() -> Result<()> { let transformer = non_streaming_transformer(); @@ -1326,10 +1359,6 @@ mod tests { let params: super::OpenAICompletionRequestParams = serde_json::from_value(input)?; assert_eq!(params.messages.len(), 4); - assert_eq!(params.messages[0].role, "system"); - assert_eq!(params.messages[1].role, "user"); - assert_eq!(params.messages[2].role, "assistant"); - assert_eq!(params.messages[3].role, "user"); Ok(()) } @@ -1365,4 +1394,21 @@ mod tests { assert!(error["error"]["param"].is_null()); assert!(error["error"]["code"].is_null()); } + + #[test] + fn validation_failure_message_returns_first_error() { + let message = super::validation_failure_message(&[ + "first issue".to_owned(), + "second issue".to_owned(), + ]); + + assert_eq!(message, "first issue"); + } + + #[test] + fn validation_failure_message_falls_back_when_no_errors() { + let message = super::validation_failure_message(&[]); + + assert!(message.contains("validation")); + } } diff --git a/paddler/src/lib.rs b/paddler/src/lib.rs index 5dd9c630..6c6968ea 100644 --- a/paddler/src/lib.rs +++ b/paddler/src/lib.rs @@ -38,4 +38,11 @@ pub mod snapshots_stream; #[cfg(feature = "web_admin_panel")] pub mod static_files; pub mod subscribes_to_updates; +pub mod tool_call_buffer; +pub mod tool_call_event; +pub mod tool_call_parse_error; +pub mod tool_call_parser; +pub mod tool_call_pipeline; +pub mod tool_call_validation_error; +pub mod tool_call_validator; pub mod websocket_session_controller; diff --git a/paddler/src/tool_call_buffer.rs b/paddler/src/tool_call_buffer.rs new file mode 100644 index 00000000..29651e81 --- /dev/null +++ b/paddler/src/tool_call_buffer.rs @@ -0,0 +1,123 @@ +/// Append-only string buffer for tool-call text fragments. +/// +/// Single responsibility: accumulate fragments emitted by the scheduler and +/// hand the whole buffer off when the classifier signals end-of-tool-call. +/// Pure data — every method is deterministic and unit-testable without a +/// model. +#[derive(Debug, Default)] +pub struct ToolCallBuffer { + accumulated: String, +} + +impl ToolCallBuffer { + #[must_use] + pub const fn new() -> Self { + Self { + accumulated: String::new(), + } + } + + pub fn append(&mut self, fragment: &str) { + self.accumulated.push_str(fragment); + } + + #[must_use] + pub fn as_str(&self) -> &str { + &self.accumulated + } + + pub fn clear(&mut self) { + self.accumulated.clear(); + } + + /// Consumes the buffer's contents, leaving it empty. + #[must_use] + pub fn take(&mut self) -> String { + std::mem::take(&mut self.accumulated) + } + + #[must_use] + pub fn is_empty(&self) -> bool { + self.accumulated.is_empty() + } + + #[must_use] + pub fn len(&self) -> usize { + self.accumulated.len() + } +} + +#[cfg(test)] +mod tests { + use super::ToolCallBuffer; + + #[test] + fn new_is_empty() { + let buffer = ToolCallBuffer::new(); + + assert!(buffer.is_empty()); + assert_eq!(buffer.len(), 0); + assert_eq!(buffer.as_str(), ""); + } + + #[test] + fn append_extends_buffer() { + let mut buffer = ToolCallBuffer::new(); + buffer.append("hello"); + + assert_eq!(buffer.as_str(), "hello"); + assert_eq!(buffer.len(), 5); + assert!(!buffer.is_empty()); + } + + #[test] + fn multiple_appends_concatenate() { + let mut buffer = ToolCallBuffer::new(); + buffer.append("\n"); + buffer.append("{\"name\":\"x\"}"); + buffer.append("\n"); + + assert_eq!( + buffer.as_str(), + "\n{\"name\":\"x\"}\n" + ); + } + + #[test] + fn clear_resets_to_empty() { + let mut buffer = ToolCallBuffer::new(); + buffer.append("data"); + buffer.clear(); + + assert!(buffer.is_empty()); + assert_eq!(buffer.len(), 0); + } + + #[test] + fn take_returns_contents_and_clears() { + let mut buffer = ToolCallBuffer::new(); + buffer.append("hello"); + let taken = buffer.take(); + + assert_eq!(taken, "hello"); + assert!(buffer.is_empty()); + } + + #[test] + fn take_on_empty_returns_empty_string() { + let mut buffer = ToolCallBuffer::new(); + let taken = buffer.take(); + + assert!(taken.is_empty()); + assert!(buffer.is_empty()); + } + + #[test] + fn append_handles_unicode() { + let mut buffer = ToolCallBuffer::new(); + buffer.append("héllo"); + + assert_eq!(buffer.as_str(), "héllo"); + assert_eq!(buffer.len(), "héllo".len()); + } +} diff --git a/paddler/src/tool_call_event.rs b/paddler/src/tool_call_event.rs new file mode 100644 index 00000000..6b1fad51 --- /dev/null +++ b/paddler/src/tool_call_event.rs @@ -0,0 +1,85 @@ +use paddler_types::parsed_tool_call::ParsedToolCall; + +use crate::tool_call_parse_error::ToolCallParseError; +use crate::tool_call_validation_error::ToolCallValidationError; + +/// What the [`ToolCallPipeline`](crate::tool_call_pipeline::ToolCallPipeline) +/// reports back when the scheduler asks it to flush a buffered tool-call +/// payload. Variants are explicit per outcome rather than collapsing into a +/// generic `Result`, so downstream consumers (the scheduler, the OpenAI +/// transformer) can pattern-match by intent without parsing strings. +#[derive(Debug)] +pub enum ToolCallEvent { + /// The pipeline still has nothing to report — used for partial peeks. + Pending, + /// One or more tool calls parsed and validated successfully. + Resolved(Vec), + /// The bindings parser raised an error. The buffer has been cleared. + ParseFailed(ToolCallParseError), + /// Validation rejected at least one parsed call. The accompanying + /// `Vec` is non-empty by construction. + ValidationFailed(Vec), +} + +impl ToolCallEvent { + #[must_use] + pub const fn is_resolved(&self) -> bool { + matches!(self, Self::Resolved(_)) + } + + #[must_use] + pub const fn is_failure(&self) -> bool { + matches!(self, Self::ParseFailed(_) | Self::ValidationFailed(_)) + } + + #[must_use] + pub const fn is_pending(&self) -> bool { + matches!(self, Self::Pending) + } +} + +#[cfg(test)] +mod tests { + use paddler_types::parsed_tool_call::ParsedToolCall; + + use super::ToolCallEvent; + use crate::tool_call_parse_error::ToolCallParseError; + use crate::tool_call_validation_error::ToolCallValidationError; + + #[test] + fn pending_classifies_as_pending() { + let event = ToolCallEvent::Pending; + + assert!(event.is_pending()); + assert!(!event.is_resolved()); + assert!(!event.is_failure()); + } + + #[test] + fn resolved_classifies_as_resolved() { + let event = ToolCallEvent::Resolved(vec![ParsedToolCall::default()]); + + assert!(event.is_resolved()); + assert!(!event.is_pending()); + assert!(!event.is_failure()); + } + + #[test] + fn parse_failed_classifies_as_failure() { + let event = ToolCallEvent::ParseFailed(ToolCallParseError::EmptyInput); + + assert!(event.is_failure()); + assert!(!event.is_resolved()); + assert!(!event.is_pending()); + } + + #[test] + fn validation_failed_classifies_as_failure() { + let event = ToolCallEvent::ValidationFailed(vec![ + ToolCallValidationError::UnknownToolName("x".to_owned()), + ]); + + assert!(event.is_failure()); + assert!(!event.is_resolved()); + } +} diff --git a/paddler/src/tool_call_parse_error.rs b/paddler/src/tool_call_parse_error.rs new file mode 100644 index 00000000..f24b4a09 --- /dev/null +++ b/paddler/src/tool_call_parse_error.rs @@ -0,0 +1,15 @@ +use llama_cpp_bindings::ParseChatMessageError; + +/// Why a `ToolCallParser::parse` call failed. Bindings-side errors propagate +/// verbatim so callers can decide whether to retry, surface to clients, or +/// log and continue. Empty-input is its own variant — callers asking for a +/// parse on an empty buffer almost always indicate a state-machine bug. +#[derive(Debug, thiserror::Error)] +pub enum ToolCallParseError { + #[error("tool-call parser invoked on empty buffer")] + EmptyInput, + #[error("bindings parse failed: {0}")] + Bindings(#[from] ParseChatMessageError), + #[error("could not serialize tools to JSON: {0}")] + ToolsSerialization(String), +} diff --git a/paddler/src/tool_call_parser.rs b/paddler/src/tool_call_parser.rs new file mode 100644 index 00000000..c9e6474a --- /dev/null +++ b/paddler/src/tool_call_parser.rs @@ -0,0 +1,78 @@ +use std::sync::Arc; + +use llama_cpp_bindings::ParsedChatMessage; +use llama_cpp_bindings::model::LlamaModel; + +use crate::tool_call_parse_error::ToolCallParseError; + +/// Thin wrapper around `LlamaModel::parse_chat_message`. Owns the tools +/// payload (pre-serialized to JSON once at construction) and the model +/// handle so callers can treat parsing as a pure function of the buffered +/// input string. +/// +/// Parsing happens entirely in the bindings/C++ side via llama.cpp's +/// `common_chat_parse`. This struct never deserializes JSON in Rust on +/// model output. +#[derive(Clone)] +pub struct ToolCallParser { + model: Arc, + tools_json: Arc, +} + +impl ToolCallParser { + /// Build a parser bound to the given model and tools array. `tools` is + /// expected to be a JSON-serializable list of OpenAI-style tool + /// definitions; an empty slice serializes to `"[]"` and tells the + /// underlying parser to refuse tool calls entirely. + /// + /// # Errors + /// Returns [`ToolCallParseError::ToolsSerialization`] when serde_json + /// cannot serialize the supplied tools array (in practice only on + /// non-string map keys, which the workspace types disallow upstream). + pub fn new( + model: Arc, + tools: &[serde_json::Value], + ) -> Result { + let tools_json = serde_json::to_string(tools) + .map_err(|err| ToolCallParseError::ToolsSerialization(err.to_string()))?; + + Ok(Self { + model, + tools_json: Arc::from(tools_json), + }) + } + + /// Parse a complete tool-call payload (close marker has been seen). + /// + /// # Errors + /// Returns [`ToolCallParseError::EmptyInput`] when called with no buffer + /// content, or [`ToolCallParseError::Bindings`] when the FFI raises. + pub fn parse(&self, input: &str) -> Result { + if input.is_empty() { + return Err(ToolCallParseError::EmptyInput); + } + + Ok(self.model.parse_chat_message(&self.tools_json, input, false)?) + } + + /// Parse a partial tool-call payload (still mid-stream). Lenient — the + /// underlying parser tolerates incomplete input. + /// + /// # Errors + /// Returns [`ToolCallParseError::EmptyInput`] or [`ToolCallParseError::Bindings`]. + pub fn parse_partial( + &self, + input: &str, + ) -> Result { + if input.is_empty() { + return Err(ToolCallParseError::EmptyInput); + } + + Ok(self.model.parse_chat_message(&self.tools_json, input, true)?) + } + + #[must_use] + pub fn tools_json(&self) -> &str { + &self.tools_json + } +} diff --git a/paddler/src/tool_call_pipeline.rs b/paddler/src/tool_call_pipeline.rs new file mode 100644 index 00000000..f105e247 --- /dev/null +++ b/paddler/src/tool_call_pipeline.rs @@ -0,0 +1,112 @@ +use paddler_types::parsed_tool_call::ParsedToolCall; + +use crate::tool_call_buffer::ToolCallBuffer; +use crate::tool_call_event::ToolCallEvent; +use crate::tool_call_parser::ToolCallParser; +use crate::tool_call_validator::ToolCallValidator; + +/// Stateful tool-call pipeline shared by both the OpenAI compat endpoint and +/// the internal endpoints. +/// +/// Composition only — every responsibility lives in a sibling module: +/// - [`ToolCallBuffer`] accumulates fragments. +/// - [`ToolCallParser`] turns the buffered text into structured calls via +/// the bindings. +/// - [`ToolCallValidator`] checks each call against the request's tool +/// schemas (or the JSON-object fallback when no schema is declared). +/// +/// The validator is **always** consulted: callers don't get to skip +/// validation, they get to choose between schema mode and JSON-object mode +/// at validator construction time. +pub struct ToolCallPipeline { + buffer: ToolCallBuffer, + parser: ToolCallParser, + validator: ToolCallValidator, +} + +impl ToolCallPipeline { + #[must_use] + pub fn new(parser: ToolCallParser, validator: ToolCallValidator) -> Self { + Self { + buffer: ToolCallBuffer::new(), + parser, + validator, + } + } + + /// Append a streamed tool-call text fragment to the internal buffer. + pub fn feed(&mut self, fragment: &str) { + self.buffer.append(fragment); + } + + /// Parse + validate the accumulated buffer, then clear it. + /// + /// Always returns one of `Resolved`, `ParseFailed`, or + /// `ValidationFailed`. `Pending` is reserved for `try_partial`. + pub fn finalize(&mut self) -> ToolCallEvent { + let input = self.buffer.take(); + if input.is_empty() { + return ToolCallEvent::Resolved(Vec::new()); + } + + match self.parser.parse(&input) { + Ok(parsed) => self.validate_resolved(parsed.tool_calls), + Err(err) => ToolCallEvent::ParseFailed(err), + } + } + + /// Peek at the buffer with a partial-tolerant parse. Does NOT clear the + /// buffer. Useful for emitting an intermediate "we have a name" event + /// once enough text is buffered, while still letting the final + /// `finalize` produce the canonical resolved event. + #[must_use] + pub fn try_partial(&self) -> ToolCallEvent { + let input = self.buffer.as_str(); + if input.is_empty() { + return ToolCallEvent::Pending; + } + + match self.parser.parse_partial(input) { + Ok(parsed) if parsed.tool_calls.is_empty() => ToolCallEvent::Pending, + Ok(parsed) => self.validate_resolved(parsed.tool_calls), + Err(err) => ToolCallEvent::ParseFailed(err), + } + } + + pub fn reset(&mut self) { + self.buffer.clear(); + } + + #[must_use] + pub fn buffer_is_empty(&self) -> bool { + self.buffer.is_empty() + } + + fn validate_resolved( + &self, + tool_calls: Vec, + ) -> ToolCallEvent { + let parsed_wire: Vec = tool_calls + .into_iter() + .map(|call| ParsedToolCall::new(call.id, call.name, call.arguments_json)) + .collect(); + + let mut errors = Vec::new(); + for call in &parsed_wire { + if let Err(err) = self.validator.validate(call) { + errors.push(err); + } + } + + if errors.is_empty() { + ToolCallEvent::Resolved(parsed_wire) + } else { + ToolCallEvent::ValidationFailed(errors) + } + } +} + +// Pipeline composition needs a real LlamaModel to exercise; integration tests +// live under paddler_tests/tests/qwen3_*. The constituent units — +// tool_call_buffer, tool_call_validator, and tool_call_event — each carry +// their own unit tests independent of the model. diff --git a/paddler/src/tool_call_validation_error.rs b/paddler/src/tool_call_validation_error.rs new file mode 100644 index 00000000..0a9c57f4 --- /dev/null +++ b/paddler/src/tool_call_validation_error.rs @@ -0,0 +1,26 @@ +/// One specific reason a parsed tool call failed validation. +/// +/// The variants split per failure mode rather than collapsing into a generic +/// `Other(String)` so callers can decide whether to surface the failure to +/// the model (retry-on-validation-failure) or to the client. +#[derive(Debug, thiserror::Error)] +pub enum ToolCallValidationError { + #[error("unknown tool name {0:?}")] + UnknownToolName(String), + #[error("arguments for tool {tool_name:?} are not valid JSON: {message}")] + InvalidJson { + tool_name: String, + message: String, + }, + #[error("arguments for tool {tool_name:?} must be a JSON object, got {kind}")] + NotAnObject { + tool_name: String, + /// JSON value kind word (`array`, `string`, `number`, etc.). + kind: &'static str, + }, + #[error("arguments for tool {tool_name:?} failed schema check: {message}")] + SchemaMismatch { + tool_name: String, + message: String, + }, +} diff --git a/paddler/src/tool_call_validator.rs b/paddler/src/tool_call_validator.rs new file mode 100644 index 00000000..f79ecd5d --- /dev/null +++ b/paddler/src/tool_call_validator.rs @@ -0,0 +1,341 @@ +use std::collections::HashMap; + +use jsonschema::Validator; +use paddler_types::parsed_tool_call::ParsedToolCall; +use paddler_types::request_params::continue_from_conversation_history_params::tool::Tool; +use paddler_types::request_params::continue_from_conversation_history_params::tool::tool_params::function_call::parameters::Parameters; +use paddler_types::request_params::continue_from_conversation_history_params::tool::tool_params::function_call::parameters_schema::validated_parameters_schema::ValidatedParametersSchema; +use serde_json::Value; + +use crate::tool_call_validation_error::ToolCallValidationError; + +/// Why building the validator failed. Either we couldn't render the supplied +/// parameters schema as JSON or the JSON we got back wasn't a valid +/// JSON-Schema document. +#[derive(Debug, thiserror::Error)] +pub enum ValidatorBuildError { + #[error("could not serialize tool {tool_name:?} parameters to JSON: {message}")] + SerializationFailed { tool_name: String, message: String }, + #[error("tool {tool_name:?} parameters are not a valid JSON Schema: {message}")] + InvalidSchema { tool_name: String, message: String }, +} + +/// Per-tool validation strategy. +/// +/// `JsonObjectOnly` is the fallback when the request didn't supply a JSON +/// schema for the tool's parameters — we still want to confirm the parser +/// returned a JSON object and not a stray scalar/array. `Schema` runs the +/// full `jsonschema::Validator`. +enum ValidationStrategy { + JsonObjectOnly, + Schema(Box), +} + +/// Validates [`ParsedToolCall`] payloads coming out of the bindings parser. +/// +/// Single responsibility: given a parsed tool call, decide whether the +/// arguments are well-formed under the request's tool definitions. The +/// validator is **always** consulted by the pipeline — when no schema was +/// declared for a tool the strategy defaults to a JSON-object structural +/// check rather than skipping validation entirely. +pub struct ToolCallValidator { + strategies: HashMap, +} + +impl ToolCallValidator { + /// Build a validator from the request's tools array. Tools with + /// `Parameters::Empty` get the `JsonObjectOnly` fallback; tools with + /// `Parameters::Schema(...)` get a compiled `jsonschema::Validator`. + /// + /// # Errors + /// Returns the underlying `jsonschema` error when a declared schema is + /// itself invalid — this is a request-time validation failure. + pub fn from_tools( + tools: &[Tool], + ) -> Result { + let mut strategies = HashMap::with_capacity(tools.len()); + + for tool in tools { + let Tool::Function(function_call) = tool; + let function = &function_call.function; + + let strategy = match &function.parameters { + Parameters::Empty => ValidationStrategy::JsonObjectOnly, + Parameters::Schema(schema) => { + let schema_value = serde_json::to_value(schema).map_err(|err| { + ValidatorBuildError::SerializationFailed { + tool_name: function.name.clone(), + message: err.to_string(), + } + })?; + let compiled = jsonschema::validator_for(&schema_value).map_err(|err| { + ValidatorBuildError::InvalidSchema { + tool_name: function.name.clone(), + message: err.to_string(), + } + })?; + ValidationStrategy::Schema(Box::new(compiled)) + } + }; + + strategies.insert(function.name.clone(), strategy); + } + + Ok(Self { strategies }) + } + + /// Validate the parsed tool call against its tool's strategy. Returns + /// `Ok(())` on success and a specific [`ToolCallValidationError`] + /// variant on failure. + pub fn validate(&self, parsed: &ParsedToolCall) -> Result<(), ToolCallValidationError> { + let strategy = self.strategies.get(&parsed.name).ok_or_else(|| { + ToolCallValidationError::UnknownToolName(parsed.name.clone()) + })?; + + let arguments_value: Value = serde_json::from_str(&parsed.arguments_json).map_err(|err| { + ToolCallValidationError::InvalidJson { + tool_name: parsed.name.clone(), + message: err.to_string(), + } + })?; + + if !arguments_value.is_object() { + return Err(ToolCallValidationError::NotAnObject { + tool_name: parsed.name.clone(), + kind: json_value_kind(&arguments_value), + }); + } + + match strategy { + ValidationStrategy::JsonObjectOnly => Ok(()), + ValidationStrategy::Schema(validator) => { + let mut messages: Vec = + validator.iter_errors(&arguments_value).map(|err| err.to_string()).collect(); + + if messages.is_empty() { + Ok(()) + } else { + Err(ToolCallValidationError::SchemaMismatch { + tool_name: parsed.name.clone(), + message: messages.remove(0), + }) + } + } + } + } + + #[must_use] + pub fn known_tool_names(&self) -> Vec<&str> { + self.strategies.keys().map(String::as_str).collect() + } +} + +const fn json_value_kind(value: &Value) -> &'static str { + match value { + Value::Null => "null", + Value::Bool(_) => "bool", + Value::Number(_) => "number", + Value::String(_) => "string", + Value::Array(_) => "array", + Value::Object(_) => "object", + } +} + +#[cfg(test)] +mod tests { + use paddler_types::parsed_tool_call::ParsedToolCall; + use paddler_types::request_params::continue_from_conversation_history_params::tool::Tool; + use paddler_types::request_params::continue_from_conversation_history_params::tool::tool_params::FunctionCall; + use paddler_types::request_params::continue_from_conversation_history_params::tool::tool_params::function_call::function::Function; + use paddler_types::request_params::continue_from_conversation_history_params::tool::tool_params::function_call::parameters::Parameters; + use paddler_types::request_params::continue_from_conversation_history_params::tool::tool_params::function_call::parameters_schema::validated_parameters_schema::ValidatedParametersSchema; + use serde_json::Map; + use serde_json::Value; + + use super::ToolCallValidator; + use super::json_value_kind; + use crate::tool_call_validation_error::ToolCallValidationError; + + fn weather_tool_with_schema() -> Tool { + let mut properties = Map::new(); + properties.insert( + "location".to_owned(), + serde_json::json!({"type": "string", "description": "city"}), + ); + + Tool::Function(FunctionCall { + function: Function { + name: "get_weather".to_owned(), + description: "fetch weather".to_owned(), + parameters: Parameters::Schema(ValidatedParametersSchema { + schema_type: "object".to_owned(), + properties: Some(properties), + required: Some(vec!["location".to_owned()]), + additional_properties: Some(Value::Bool(false)), + }), + }, + }) + } + + fn schemaless_tool() -> Tool { + Tool::Function(FunctionCall { + function: Function { + name: "freeform".to_owned(), + description: "tool with no schema".to_owned(), + parameters: Parameters::Empty, + }, + }) + } + + #[test] + fn schema_validator_accepts_matching_arguments() { + let validator = + ToolCallValidator::from_tools(&[weather_tool_with_schema()]).unwrap(); + let parsed = ParsedToolCall::new( + "id".to_owned(), + "get_weather".to_owned(), + "{\"location\":\"Paris\"}".to_owned(), + ); + + assert!(validator.validate(&parsed).is_ok()); + } + + #[test] + fn schema_validator_rejects_missing_required_field() { + let validator = + ToolCallValidator::from_tools(&[weather_tool_with_schema()]).unwrap(); + let parsed = ParsedToolCall::new( + "id".to_owned(), + "get_weather".to_owned(), + "{}".to_owned(), + ); + + match validator.validate(&parsed) { + Err(ToolCallValidationError::SchemaMismatch { tool_name, .. }) => { + assert_eq!(tool_name, "get_weather"); + } + other => panic!("expected SchemaMismatch, got {other:?}"), + } + } + + #[test] + fn schema_validator_rejects_wrong_type() { + let validator = + ToolCallValidator::from_tools(&[weather_tool_with_schema()]).unwrap(); + let parsed = ParsedToolCall::new( + "id".to_owned(), + "get_weather".to_owned(), + "{\"location\":42}".to_owned(), + ); + + match validator.validate(&parsed) { + Err(ToolCallValidationError::SchemaMismatch { .. }) => {} + other => panic!("expected SchemaMismatch, got {other:?}"), + } + } + + #[test] + fn unknown_tool_name_returns_error() { + let validator = + ToolCallValidator::from_tools(&[weather_tool_with_schema()]).unwrap(); + let parsed = ParsedToolCall::new( + "id".to_owned(), + "set_thermostat".to_owned(), + "{\"value\":21}".to_owned(), + ); + + match validator.validate(&parsed) { + Err(ToolCallValidationError::UnknownToolName(name)) => { + assert_eq!(name, "set_thermostat"); + } + other => panic!("expected UnknownToolName, got {other:?}"), + } + } + + #[test] + fn invalid_json_returns_invalid_json_error() { + let validator = + ToolCallValidator::from_tools(&[weather_tool_with_schema()]).unwrap(); + let parsed = ParsedToolCall::new( + "id".to_owned(), + "get_weather".to_owned(), + "not json".to_owned(), + ); + + match validator.validate(&parsed) { + Err(ToolCallValidationError::InvalidJson { tool_name, .. }) => { + assert_eq!(tool_name, "get_weather"); + } + other => panic!("expected InvalidJson, got {other:?}"), + } + } + + #[test] + fn non_object_arguments_return_not_an_object_error() { + let validator = ToolCallValidator::from_tools(&[schemaless_tool()]).unwrap(); + let parsed = ParsedToolCall::new( + "id".to_owned(), + "freeform".to_owned(), + "[1, 2, 3]".to_owned(), + ); + + match validator.validate(&parsed) { + Err(ToolCallValidationError::NotAnObject { tool_name, kind }) => { + assert_eq!(tool_name, "freeform"); + assert_eq!(kind, "array"); + } + other => panic!("expected NotAnObject, got {other:?}"), + } + } + + #[test] + fn schemaless_tool_accepts_any_object() { + let validator = ToolCallValidator::from_tools(&[schemaless_tool()]).unwrap(); + let parsed = ParsedToolCall::new( + "id".to_owned(), + "freeform".to_owned(), + "{\"x\":1,\"y\":2}".to_owned(), + ); + + assert!(validator.validate(&parsed).is_ok()); + } + + #[test] + fn known_tool_names_returns_all_registered_names() { + let validator = ToolCallValidator::from_tools(&[ + weather_tool_with_schema(), + schemaless_tool(), + ]) + .unwrap(); + + let mut names = validator.known_tool_names(); + names.sort_unstable(); + + assert_eq!(names, vec!["freeform", "get_weather"]); + } + + #[test] + fn json_value_kind_reports_each_kind() { + assert_eq!(json_value_kind(&Value::Null), "null"); + assert_eq!(json_value_kind(&Value::Bool(true)), "bool"); + assert_eq!(json_value_kind(&Value::Number(serde_json::Number::from(1))), "number"); + assert_eq!(json_value_kind(&Value::String("x".to_owned())), "string"); + assert_eq!(json_value_kind(&Value::Array(vec![])), "array"); + assert_eq!(json_value_kind(&Value::Object(Map::new())), "object"); + } + + #[test] + fn empty_tools_yields_validator_that_rejects_any_call() { + let validator = ToolCallValidator::from_tools(&[]).unwrap(); + let parsed = ParsedToolCall::new( + "id".to_owned(), + "anything".to_owned(), + "{}".to_owned(), + ); + + assert!(matches!( + validator.validate(&parsed), + Err(ToolCallValidationError::UnknownToolName(_)) + )); + } +} diff --git a/paddler_tests/tests/qwen3_classifier_detects_tool_call_markers.rs b/paddler_tests/tests/qwen3_classifier_detects_tool_call_markers.rs deleted file mode 100644 index 7404f5ad..00000000 --- a/paddler_tests/tests/qwen3_classifier_detects_tool_call_markers.rs +++ /dev/null @@ -1,63 +0,0 @@ -#![cfg(feature = "tests_that_use_llms")] - -use anyhow::Context as _; -use anyhow::Result; -use llama_cpp_bindings::llama_backend::LlamaBackend; -use llama_cpp_bindings::model::LlamaModel; -use llama_cpp_bindings::model::params::LlamaModelParams; -use paddler_tests::model_card::ModelCard; -use paddler_tests::model_card::qwen3_0_6b::qwen3_0_6b; -use paddler_types::huggingface_model_reference::HuggingFaceModelReference; - -#[serial_test::file_serial(model_load, path => "../target/model_load.lock")] -#[tokio::test(flavor = "multi_thread")] -async fn qwen3_classifier_detects_tool_call_markers() -> Result<()> { - let ModelCard { - reference: - HuggingFaceModelReference { - filename, - repo_id, - revision, - }, - .. - } = qwen3_0_6b(); - - let api = hf_hub::api::sync::ApiBuilder::from_env() - .build() - .context("failed to build hf-hub API client")?; - let model_path = api - .repo(hf_hub::Repo::with_revision( - repo_id, - hf_hub::RepoType::Model, - revision, - )) - .get(&filename) - .context("failed to fetch Qwen3 model from Hugging Face")?; - - let backend = LlamaBackend::init().context("failed to init llama backend")?; - let model_params = LlamaModelParams::default(); - let model = LlamaModel::load_from_file(&backend, &model_path, &model_params) - .context("failed to load Qwen3 model")?; - - let classifier = model - .sampled_token_classifier() - .context("failed to build sampled-token classifier for Qwen3")?; - - assert!( - classifier.markers().reasoning.is_some(), - "expected Qwen3 to expose reasoning markers; got {:?}", - classifier.markers() - ); - - let (no_tools, with_tools) = model - .diagnose_tool_call_synthetic_renders() - .context("failed to render synthetic templates for diagnosis")?; - - assert!( - classifier.markers().tool_call.is_some(), - "expected Qwen3 to expose tool-call markers; got markers={:?}\n--- no_tools render ---\n{no_tools}\n--- with_tools render ---\n{with_tools}", - classifier.markers() - ); - - Ok(()) -} diff --git a/paddler_types/src/generated_token_result.rs b/paddler_types/src/generated_token_result.rs index a4f1c158..04148f24 100644 --- a/paddler_types/src/generated_token_result.rs +++ b/paddler_types/src/generated_token_result.rs @@ -2,6 +2,7 @@ use serde::Deserialize; use serde::Serialize; use crate::generation_summary::GenerationSummary; +use crate::parsed_tool_call::ParsedToolCall; use crate::streamable_result::StreamableResult; #[derive(Debug, Deserialize, Serialize)] @@ -18,7 +19,10 @@ pub enum GeneratedTokenResult { MultimodalNotSupported(String), ReasoningToken(String), SamplerError(String), + ToolCallParseFailed(String), + ToolCallParsed(Vec), ToolCallToken(String), + ToolCallValidationFailed(Vec), UndeterminableToken(String), } @@ -44,6 +48,26 @@ impl GeneratedTokenResult { _ => None, } } + + /// True iff the variant carries a structured tool-call event resolved by + /// the parser (not the raw streamed `ToolCallToken` text). The OpenAI + /// compat layer keys off this to decide between text-streaming and + /// `delta.tool_calls` emission. + #[must_use] + pub const fn is_tool_call_parsed(&self) -> bool { + matches!(self, Self::ToolCallParsed(_)) + } + + /// True iff the variant signals that tool-call parsing or validation + /// failed. These are informational events — they do **not** terminate + /// the request; the scheduler keeps streaming subsequent content. + #[must_use] + pub const fn is_tool_call_failure(&self) -> bool { + matches!( + self, + Self::ToolCallParseFailed(_) | Self::ToolCallValidationFailed(_) + ) + } } impl StreamableResult for GeneratedTokenResult { @@ -126,4 +150,31 @@ mod tests { fn undeterminable_token_is_not_done() { assert!(!GeneratedTokenResult::UndeterminableToken("ambiguous".to_owned()).is_done()); } + + #[test] + fn tool_call_parsed_is_not_done() { + let event = GeneratedTokenResult::ToolCallParsed(vec![]); + + assert!(!event.is_done()); + assert!(event.is_tool_call_parsed()); + assert!(!event.is_tool_call_failure()); + } + + #[test] + fn tool_call_parse_failed_is_failure_but_not_done() { + let event = GeneratedTokenResult::ToolCallParseFailed("oops".to_owned()); + + assert!(!event.is_done()); + assert!(!event.is_tool_call_parsed()); + assert!(event.is_tool_call_failure()); + } + + #[test] + fn tool_call_validation_failed_is_failure_but_not_done() { + let event = GeneratedTokenResult::ToolCallValidationFailed(vec!["missing".to_owned()]); + + assert!(!event.is_done()); + assert!(!event.is_tool_call_parsed()); + assert!(event.is_tool_call_failure()); + } } diff --git a/paddler_types/src/lib.rs b/paddler_types/src/lib.rs index d74e9c90..2aa84e2c 100644 --- a/paddler_types/src/lib.rs +++ b/paddler_types/src/lib.rs @@ -33,6 +33,7 @@ pub mod kv_cache_dtype; pub mod media_marker; pub mod model_metadata; pub mod normalization; +pub mod parsed_tool_call; pub mod pooling_type; pub mod request_params; pub mod rpc_message; diff --git a/paddler_types/src/parsed_tool_call.rs b/paddler_types/src/parsed_tool_call.rs new file mode 100644 index 00000000..de4d9209 --- /dev/null +++ b/paddler_types/src/parsed_tool_call.rs @@ -0,0 +1,64 @@ +use serde::Deserialize; +use serde::Serialize; + +/// Wire-format value object for one parsed tool call. +/// +/// Mirrors the bindings' `llama_cpp_bindings::ParsedToolCall` so it can be +/// serialised straight into both Paddler-native SSE streams and OpenAI-compat +/// `delta.tool_calls` chunks. The `arguments_json` field is the raw JSON +/// string emitted by the parser; downstream consumers should validate it +/// against the tool's parameter schema before acting on it. +#[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq, Serialize)] +#[serde(deny_unknown_fields)] +pub struct ParsedToolCall { + pub id: String, + pub name: String, + pub arguments_json: String, +} + +impl ParsedToolCall { + #[must_use] + pub const fn new(id: String, name: String, arguments_json: String) -> Self { + Self { + id, + name, + arguments_json, + } + } +} + +#[cfg(test)] +mod tests { + use super::ParsedToolCall; + + #[test] + fn new_assigns_fields_in_order() { + let parsed = ParsedToolCall::new( + "id-1".to_owned(), + "tool".to_owned(), + "{}".to_owned(), + ); + + assert_eq!(parsed.id, "id-1"); + assert_eq!(parsed.name, "tool"); + assert_eq!(parsed.arguments_json, "{}"); + } + + #[test] + fn default_is_empty_strings() { + let parsed = ParsedToolCall::default(); + + assert!(parsed.id.is_empty()); + assert!(parsed.name.is_empty()); + assert!(parsed.arguments_json.is_empty()); + } + + #[test] + fn rejects_unknown_fields_during_deserialization() { + let json = + "{\"id\":\"x\",\"name\":\"y\",\"arguments_json\":\"{}\",\"extra\":\"nope\"}"; + let result = serde_json::from_str::(json); + + assert!(result.is_err()); + } +} From 33e0f4cb1a46eadffc7af8515d873d8410dfffe9 Mon Sep 17 00:00:00 2001 From: Mateusz Charytoniuk Date: Tue, 5 May 2026 20:34:35 +0200 Subject: [PATCH 10/51] Consume shared bindings-types crate; drop value-object duplication --- Cargo.lock | 12 ++ Cargo.toml | 1 + .../src/agent/continuous_batch_scheduler.rs | 19 +- paddler/src/agent/mod.rs | 1 - .../src/agent/token_usage_from_bindings.rs | 16 -- .../http_route/post_chat_completions.rs | 88 ++++----- paddler/src/tool_call_buffer.rs | 11 +- paddler/src/tool_call_event.rs | 10 - paddler/src/tool_call_parse_error.rs | 4 - paddler/src/tool_call_parser.rs | 27 --- paddler/src/tool_call_pipeline.rs | 50 ++--- paddler/src/tool_call_validation_error.rs | 16 -- paddler/src/tool_call_validator.rs | 178 ++++++------------ .../paddler_client/inference_message.py | 41 ++++ .../paddler_client/parsed_tool_call.py | 28 +++ .../paddler_client/tool_call_arguments.py | 26 +++ .../tests/test_inference_message.py | 89 +++++++++ .../tests/test_parsed_tool_call.py | 42 +++++ .../tests/test_tool_call_arguments.py | 28 +++ paddler_tests/src/inference_http_client.rs | 1 + ..._completions_non_streaming_returns_text.rs | 3 +- ...t_concurrent_requests_independent_usage.rs | 77 ++++++++ ...l_endpoint_emits_tool_call_parsed_event.rs | 104 ++++++++++ ...ernal_endpoint_max_tokens_usage_matches.rs | 65 +++++++ ...n3_internal_endpoint_pure_content_usage.rs | 58 ++++++ ...ming_emits_tool_calls_for_function_tool.rs | 37 +++- ...nai_non_streaming_usage_with_tool_calls.rs | 81 ++++++++ ...ming_emits_tool_calls_for_function_tool.rs | 43 ++++- ...streaming_usage_breakdown_with_thinking.rs | 59 ++++++ paddler_types/Cargo.toml | 1 + paddler_types/src/generated_token_result.rs | 7 - paddler_types/src/lib.rs | 12 +- paddler_types/src/parsed_tool_call.rs | 64 ------- paddler_types/src/token_usage.rs | 30 --- 34 files changed, 914 insertions(+), 415 deletions(-) delete mode 100644 paddler/src/agent/token_usage_from_bindings.rs create mode 100644 paddler_client_python/paddler_client/parsed_tool_call.py create mode 100644 paddler_client_python/paddler_client/tool_call_arguments.py create mode 100644 paddler_client_python/tests/test_parsed_tool_call.py create mode 100644 paddler_client_python/tests/test_tool_call_arguments.py create mode 100644 paddler_tests/tests/qwen3_internal_endpoint_concurrent_requests_independent_usage.rs create mode 100644 paddler_tests/tests/qwen3_internal_endpoint_emits_tool_call_parsed_event.rs create mode 100644 paddler_tests/tests/qwen3_internal_endpoint_max_tokens_usage_matches.rs create mode 100644 paddler_tests/tests/qwen3_internal_endpoint_pure_content_usage.rs create mode 100644 paddler_tests/tests/qwen3_openai_non_streaming_usage_with_tool_calls.rs create mode 100644 paddler_tests/tests/qwen3_openai_streaming_usage_breakdown_with_thinking.rs delete mode 100644 paddler_types/src/parsed_tool_call.rs delete mode 100644 paddler_types/src/token_usage.rs diff --git a/Cargo.lock b/Cargo.lock index 99bfe641..a196f0cf 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3533,6 +3533,8 @@ dependencies = [ "encoding_rs", "enumflags2", "llama-cpp-bindings-sys", + "llama-cpp-bindings-types", + "serde_json", "thiserror 2.0.18", "tracing", "tracing-core", @@ -3557,6 +3559,15 @@ dependencies = [ "llama-cpp-bindings-build", ] +[[package]] +name = "llama-cpp-bindings-types" +version = "0.5.0" +dependencies = [ + "serde", + "serde_json", + "thiserror 2.0.18", +] + [[package]] name = "local-channel" version = "0.1.5" @@ -4697,6 +4708,7 @@ version = "3.1.2" dependencies = [ "anyhow", "jsonschema", + "llama-cpp-bindings-types", "serde", "serde_json", ] diff --git a/Cargo.toml b/Cargo.toml index 45fcf126..56261f19 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -37,6 +37,7 @@ indoc = "2" jsonschema = { version = "0.37", default-features = false } llama-cpp-bindings = { path = "../llama-cpp-bindings/llama-cpp-bindings" } llama-cpp-bindings-sys = { path = "../llama-cpp-bindings/llama-cpp-bindings-sys" } +llama-cpp-bindings-types = { path = "../llama-cpp-bindings/llama-cpp-bindings-types" } base64 = "0.22" log = "0.4" mime_guess = "2" diff --git a/paddler/src/agent/continuous_batch_scheduler.rs b/paddler/src/agent/continuous_batch_scheduler.rs index b282b542..ae6a87a8 100644 --- a/paddler/src/agent/continuous_batch_scheduler.rs +++ b/paddler/src/agent/continuous_batch_scheduler.rs @@ -44,7 +44,6 @@ use crate::agent::resolve_grammar::resolve_grammar; use crate::agent::sample_token_at_batch_index::sample_token_at_batch_index; use crate::agent::sampling_outcome::SamplingOutcome; use crate::agent::sequence_id_pool::SequenceIdPool; -use crate::agent::token_usage_from_bindings::token_usage_from_bindings; use crate::decoded_image::DecodedImage; use crate::tool_call_parser::ToolCallParser; use crate::tool_call_pipeline::ToolCallPipeline; @@ -386,10 +385,6 @@ impl ContinuousBatchScheduler { Some(ToolCallPipeline::new(parser, validator)) } - #[expect( - clippy::too_many_arguments, - reason = "text-prompt acceptance ties together prompt, sampler, classifier, tool-call pipeline, and the two channels" - )] fn accept_text_prompt( &mut self, prompt: &str, @@ -832,7 +827,7 @@ impl ContinuousBatchScheduler { for active_request in &mut self.active_requests { if active_request.is_stop_requested() { let summary = GenerationSummary { - usage: token_usage_from_bindings(active_request.token_classifier.usage()), + usage: *active_request.token_classifier.usage(), }; active_request.complete_with_outcome( @@ -1021,7 +1016,7 @@ impl ContinuousBatchScheduler { if self.scheduler_context.model.is_eog_token(&sampled_token) { let summary = GenerationSummary { - usage: token_usage_from_bindings(active_request.token_classifier.usage()), + usage: *active_request.token_classifier.usage(), }; active_request.complete_with_outcome( @@ -1117,9 +1112,9 @@ impl ContinuousBatchScheduler { } let usage = active_request.token_classifier.usage(); - let completion_so_far = usage.content_tokens() - + usage.reasoning_tokens() - + usage.undeterminable_tokens(); + let completion_so_far = usage.content_tokens + + usage.reasoning_tokens + + usage.undeterminable_tokens; #[expect( clippy::cast_sign_loss, reason = "max_tokens is non-negative by API contract" @@ -1128,7 +1123,7 @@ impl ContinuousBatchScheduler { if completion_so_far >= max_tokens_u64 { let summary = GenerationSummary { - usage: token_usage_from_bindings(active_request.token_classifier.usage()), + usage: *active_request.token_classifier.usage(), }; active_request.complete_with_outcome( @@ -1358,7 +1353,7 @@ impl ContinuousBatchScheduler { "{:?}: cleaned up sequence {} ({} completion tokens generated)", self.scheduler_context.agent_name, removed_request.sequence_id, - usage.content_tokens() + usage.reasoning_tokens() + usage.undeterminable_tokens(), + usage.content_tokens + usage.reasoning_tokens + usage.undeterminable_tokens, ); } } diff --git a/paddler/src/agent/mod.rs b/paddler/src/agent/mod.rs index b12c9f85..0f5a7789 100644 --- a/paddler/src/agent/mod.rs +++ b/paddler/src/agent/mod.rs @@ -28,4 +28,3 @@ pub mod resolved_grammar; pub mod sample_token_at_batch_index; pub mod sampling_outcome; pub mod sequence_id_pool; -pub mod token_usage_from_bindings; diff --git a/paddler/src/agent/token_usage_from_bindings.rs b/paddler/src/agent/token_usage_from_bindings.rs deleted file mode 100644 index 8708dc10..00000000 --- a/paddler/src/agent/token_usage_from_bindings.rs +++ /dev/null @@ -1,16 +0,0 @@ -use llama_cpp_bindings::TokenUsage as BindingsTokenUsage; -use paddler_types::token_usage::TokenUsage; - -#[must_use] -pub const fn token_usage_from_bindings(usage: &BindingsTokenUsage) -> TokenUsage { - TokenUsage { - prompt_tokens: usage.prompt_tokens(), - cached_prompt_tokens: usage.cached_prompt_tokens(), - input_image_tokens: usage.input_image_tokens(), - input_audio_tokens: usage.input_audio_tokens(), - content_tokens: usage.content_tokens(), - reasoning_tokens: usage.reasoning_tokens(), - tool_call_tokens: usage.tool_call_tokens(), - undeterminable_tokens: usage.undeterminable_tokens(), - } -} diff --git a/paddler/src/balancer/compatibility/openai_service/http_route/post_chat_completions.rs b/paddler/src/balancer/compatibility/openai_service/http_route/post_chat_completions.rs index 13564d88..ae3cd1cf 100644 --- a/paddler/src/balancer/compatibility/openai_service/http_route/post_chat_completions.rs +++ b/paddler/src/balancer/compatibility/openai_service/http_route/post_chat_completions.rs @@ -21,6 +21,7 @@ use paddler_types::inference_client::Response as OutgoingResponse; use paddler_types::jsonrpc::ErrorEnvelope; use paddler_types::jsonrpc::ResponseEnvelope; use paddler_types::parsed_tool_call::ParsedToolCall; +use paddler_types::parsed_tool_call::ToolCallArguments; use paddler_types::request_params::ContinueFromConversationHistoryParams; use paddler_types::request_params::continue_from_conversation_history_params::tool::Tool; use paddler_types::request_params::continue_from_conversation_history_params::tool::tool_params::function_call::parameters_schema::raw_parameters_schema::RawParametersSchema; @@ -85,6 +86,15 @@ fn validation_failure_message(errors: &[String]) -> String { .unwrap_or_else(|| "tool call failed validation".to_owned()) } +fn arguments_to_openai_string(arguments: &ToolCallArguments) -> String { + match arguments { + ToolCallArguments::ValidJson(value) => { + serde_json::to_string(value).unwrap_or_else(|_| String::new()) + } + ToolCallArguments::InvalidJson(raw) => raw.clone(), + } +} + fn server_error_chunk(description: &str) -> TransformResult { TransformResult::Error(openai_error_json("server_error", description).to_string()) } @@ -194,7 +204,7 @@ impl OpenAIStreamingResponseTransformer { "type": "function", "function": { "name": call.name, - "arguments": call.arguments_json, + "arguments": arguments_to_openai_string(&call.arguments), } }) }) @@ -329,13 +339,6 @@ impl TransformsOutgoingMessage for OpenAIStreamingResponseTransformer { response: OutgoingResponse::GeneratedToken(GeneratedTokenResult::ToolCallParsed(parsed_calls)), }) => self.handle_tool_call_parsed(&request_id, &parsed_calls), - OutgoingMessage::Response(ResponseEnvelope { - response: - OutgoingResponse::GeneratedToken(GeneratedTokenResult::ToolCallParseFailed( - description, - )), - .. - }) => Ok(vec![server_error_chunk(&description)]), OutgoingMessage::Response(ResponseEnvelope { response: OutgoingResponse::GeneratedToken(GeneratedTokenResult::ToolCallValidationFailed( @@ -357,7 +360,8 @@ impl TransformsOutgoingMessage for OpenAIStreamingResponseTransformer { | GeneratedTokenResult::GrammarSyntaxError(description) | GeneratedTokenResult::ImageDecodingFailed(description) | GeneratedTokenResult::MultimodalNotSupported(description) - | GeneratedTokenResult::SamplerError(description), + | GeneratedTokenResult::SamplerError(description) + | GeneratedTokenResult::ToolCallParseFailed(description), ), .. }) @@ -391,7 +395,7 @@ impl TransformsOutgoingMessage for OpenAIStreamingResponseTransformer { } } -#[derive(Default)] +#[derive(Clone, Default)] struct OpenAINonStreamingState { content: String, reasoning: String, @@ -406,29 +410,29 @@ struct OpenAINonStreamingResponseTransformer { impl OpenAINonStreamingResponseTransformer { fn append_content(&self, text: &str) -> Result<()> { - let mut state = self - .state + self.state .lock() - .map_err(|err| anyhow!("non-streaming state mutex poisoned: {err}"))?; - state.content.push_str(text); + .map_err(|err| anyhow!("non-streaming state mutex poisoned: {err}"))? + .content + .push_str(text); Ok(()) } fn append_reasoning(&self, text: &str) -> Result<()> { - let mut state = self - .state + self.state .lock() - .map_err(|err| anyhow!("non-streaming state mutex poisoned: {err}"))?; - state.reasoning.push_str(text); + .map_err(|err| anyhow!("non-streaming state mutex poisoned: {err}"))? + .reasoning + .push_str(text); Ok(()) } fn append_tool_calls(&self, parsed_calls: Vec) -> Result<()> { - let mut state = self - .state + self.state .lock() - .map_err(|err| anyhow!("non-streaming state mutex poisoned: {err}"))?; - state.tool_calls.extend(parsed_calls); + .map_err(|err| anyhow!("non-streaming state mutex poisoned: {err}"))? + .tool_calls + .extend(parsed_calls); Ok(()) } @@ -437,35 +441,32 @@ impl OpenAINonStreamingResponseTransformer { request_id: &str, summary: &GenerationSummary, ) -> Result { - let state = self - .state - .lock() - .map_err(|err| anyhow!("non-streaming state mutex poisoned: {err}"))?; + let snapshot = self.snapshot_state()?; - let has_tool_calls = !state.tool_calls.is_empty(); + let has_tool_calls = !snapshot.tool_calls.is_empty(); let finish_reason = if has_tool_calls { "tool_calls" } else { "stop" }; let mut message_obj = json!({ "role": "assistant", - "content": if state.content.is_empty() && has_tool_calls { + "content": if snapshot.content.is_empty() && has_tool_calls { serde_json::Value::Null } else { - json!(state.content) + json!(snapshot.content) }, "refusal": null, "annotations": [] }); - if !state.reasoning.is_empty() + if !snapshot.reasoning.is_empty() && let Some(map) = message_obj.as_object_mut() { - map.insert("reasoning_content".to_owned(), json!(state.reasoning)); + map.insert("reasoning_content".to_owned(), json!(snapshot.reasoning)); } if has_tool_calls && let Some(map) = message_obj.as_object_mut() { - let tool_calls_json: Vec = state + let tool_calls_json: Vec = snapshot .tool_calls .iter() .map(|call| { @@ -474,7 +475,7 @@ impl OpenAINonStreamingResponseTransformer { "type": "function", "function": { "name": call.name, - "arguments": call.arguments_json, + "arguments": arguments_to_openai_string(&call.arguments), } }) }) @@ -499,6 +500,14 @@ impl OpenAINonStreamingResponseTransformer { "service_tier": "default" }))?) } + + fn snapshot_state(&self) -> Result { + let state = self + .state + .lock() + .map_err(|err| anyhow!("non-streaming state mutex poisoned: {err}"))?; + Ok(state.clone()) + } } #[async_trait] @@ -535,13 +544,6 @@ impl TransformsOutgoingMessage for OpenAINonStreamingResponseTransformer { self.append_tool_calls(parsed_calls)?; Ok(vec![]) } - OutgoingMessage::Response(ResponseEnvelope { - response: - OutgoingResponse::GeneratedToken(GeneratedTokenResult::ToolCallParseFailed( - description, - )), - .. - }) => Ok(vec![server_error_chunk(&description)]), OutgoingMessage::Response(ResponseEnvelope { response: OutgoingResponse::GeneratedToken(GeneratedTokenResult::ToolCallValidationFailed( @@ -565,7 +567,8 @@ impl TransformsOutgoingMessage for OpenAINonStreamingResponseTransformer { | GeneratedTokenResult::GrammarSyntaxError(description) | GeneratedTokenResult::ImageDecodingFailed(description) | GeneratedTokenResult::MultimodalNotSupported(description) - | GeneratedTokenResult::SamplerError(description), + | GeneratedTokenResult::SamplerError(description) + | GeneratedTokenResult::ToolCallParseFailed(description), ), .. }) @@ -713,6 +716,7 @@ mod tests { use paddler_types::jsonrpc::ErrorEnvelope; use paddler_types::jsonrpc::ResponseEnvelope; use paddler_types::parsed_tool_call::ParsedToolCall; + use paddler_types::parsed_tool_call::ToolCallArguments; use paddler_types::token_usage::TokenUsage; use super::OpenAINonStreamingResponseTransformer; @@ -819,7 +823,7 @@ mod tests { ParsedToolCall::new( "call_x".to_owned(), "get_weather".to_owned(), - "{\"location\":\"Paris\"}".to_owned(), + ToolCallArguments::ValidJson(serde_json::json!({"location": "Paris"})), ) } diff --git a/paddler/src/tool_call_buffer.rs b/paddler/src/tool_call_buffer.rs index 29651e81..cb214933 100644 --- a/paddler/src/tool_call_buffer.rs +++ b/paddler/src/tool_call_buffer.rs @@ -1,9 +1,3 @@ -/// Append-only string buffer for tool-call text fragments. -/// -/// Single responsibility: accumulate fragments emitted by the scheduler and -/// hand the whole buffer off when the classifier signals end-of-tool-call. -/// Pure data — every method is deterministic and unit-testable without a -/// model. #[derive(Debug, Default)] pub struct ToolCallBuffer { accumulated: String, @@ -30,19 +24,18 @@ impl ToolCallBuffer { self.accumulated.clear(); } - /// Consumes the buffer's contents, leaving it empty. #[must_use] pub fn take(&mut self) -> String { std::mem::take(&mut self.accumulated) } #[must_use] - pub fn is_empty(&self) -> bool { + pub const fn is_empty(&self) -> bool { self.accumulated.is_empty() } #[must_use] - pub fn len(&self) -> usize { + pub const fn len(&self) -> usize { self.accumulated.len() } } diff --git a/paddler/src/tool_call_event.rs b/paddler/src/tool_call_event.rs index 6b1fad51..3d7cd7ec 100644 --- a/paddler/src/tool_call_event.rs +++ b/paddler/src/tool_call_event.rs @@ -3,21 +3,11 @@ use paddler_types::parsed_tool_call::ParsedToolCall; use crate::tool_call_parse_error::ToolCallParseError; use crate::tool_call_validation_error::ToolCallValidationError; -/// What the [`ToolCallPipeline`](crate::tool_call_pipeline::ToolCallPipeline) -/// reports back when the scheduler asks it to flush a buffered tool-call -/// payload. Variants are explicit per outcome rather than collapsing into a -/// generic `Result`, so downstream consumers (the scheduler, the OpenAI -/// transformer) can pattern-match by intent without parsing strings. #[derive(Debug)] pub enum ToolCallEvent { - /// The pipeline still has nothing to report — used for partial peeks. Pending, - /// One or more tool calls parsed and validated successfully. Resolved(Vec), - /// The bindings parser raised an error. The buffer has been cleared. ParseFailed(ToolCallParseError), - /// Validation rejected at least one parsed call. The accompanying - /// `Vec` is non-empty by construction. ValidationFailed(Vec), } diff --git a/paddler/src/tool_call_parse_error.rs b/paddler/src/tool_call_parse_error.rs index f24b4a09..e76104be 100644 --- a/paddler/src/tool_call_parse_error.rs +++ b/paddler/src/tool_call_parse_error.rs @@ -1,9 +1,5 @@ use llama_cpp_bindings::ParseChatMessageError; -/// Why a `ToolCallParser::parse` call failed. Bindings-side errors propagate -/// verbatim so callers can decide whether to retry, surface to clients, or -/// log and continue. Empty-input is its own variant — callers asking for a -/// parse on an empty buffer almost always indicate a state-machine bug. #[derive(Debug, thiserror::Error)] pub enum ToolCallParseError { #[error("tool-call parser invoked on empty buffer")] diff --git a/paddler/src/tool_call_parser.rs b/paddler/src/tool_call_parser.rs index c9e6474a..6b151908 100644 --- a/paddler/src/tool_call_parser.rs +++ b/paddler/src/tool_call_parser.rs @@ -5,14 +5,6 @@ use llama_cpp_bindings::model::LlamaModel; use crate::tool_call_parse_error::ToolCallParseError; -/// Thin wrapper around `LlamaModel::parse_chat_message`. Owns the tools -/// payload (pre-serialized to JSON once at construction) and the model -/// handle so callers can treat parsing as a pure function of the buffered -/// input string. -/// -/// Parsing happens entirely in the bindings/C++ side via llama.cpp's -/// `common_chat_parse`. This struct never deserializes JSON in Rust on -/// model output. #[derive(Clone)] pub struct ToolCallParser { model: Arc, @@ -20,15 +12,6 @@ pub struct ToolCallParser { } impl ToolCallParser { - /// Build a parser bound to the given model and tools array. `tools` is - /// expected to be a JSON-serializable list of OpenAI-style tool - /// definitions; an empty slice serializes to `"[]"` and tells the - /// underlying parser to refuse tool calls entirely. - /// - /// # Errors - /// Returns [`ToolCallParseError::ToolsSerialization`] when serde_json - /// cannot serialize the supplied tools array (in practice only on - /// non-string map keys, which the workspace types disallow upstream). pub fn new( model: Arc, tools: &[serde_json::Value], @@ -42,11 +25,6 @@ impl ToolCallParser { }) } - /// Parse a complete tool-call payload (close marker has been seen). - /// - /// # Errors - /// Returns [`ToolCallParseError::EmptyInput`] when called with no buffer - /// content, or [`ToolCallParseError::Bindings`] when the FFI raises. pub fn parse(&self, input: &str) -> Result { if input.is_empty() { return Err(ToolCallParseError::EmptyInput); @@ -55,11 +33,6 @@ impl ToolCallParser { Ok(self.model.parse_chat_message(&self.tools_json, input, false)?) } - /// Parse a partial tool-call payload (still mid-stream). Lenient — the - /// underlying parser tolerates incomplete input. - /// - /// # Errors - /// Returns [`ToolCallParseError::EmptyInput`] or [`ToolCallParseError::Bindings`]. pub fn parse_partial( &self, input: &str, diff --git a/paddler/src/tool_call_pipeline.rs b/paddler/src/tool_call_pipeline.rs index f105e247..ca54a8cd 100644 --- a/paddler/src/tool_call_pipeline.rs +++ b/paddler/src/tool_call_pipeline.rs @@ -5,19 +5,6 @@ use crate::tool_call_event::ToolCallEvent; use crate::tool_call_parser::ToolCallParser; use crate::tool_call_validator::ToolCallValidator; -/// Stateful tool-call pipeline shared by both the OpenAI compat endpoint and -/// the internal endpoints. -/// -/// Composition only — every responsibility lives in a sibling module: -/// - [`ToolCallBuffer`] accumulates fragments. -/// - [`ToolCallParser`] turns the buffered text into structured calls via -/// the bindings. -/// - [`ToolCallValidator`] checks each call against the request's tool -/// schemas (or the JSON-object fallback when no schema is declared). -/// -/// The validator is **always** consulted: callers don't get to skip -/// validation, they get to choose between schema mode and JSON-object mode -/// at validator construction time. pub struct ToolCallPipeline { buffer: ToolCallBuffer, parser: ToolCallParser, @@ -26,7 +13,7 @@ pub struct ToolCallPipeline { impl ToolCallPipeline { #[must_use] - pub fn new(parser: ToolCallParser, validator: ToolCallValidator) -> Self { + pub const fn new(parser: ToolCallParser, validator: ToolCallValidator) -> Self { Self { buffer: ToolCallBuffer::new(), parser, @@ -34,15 +21,10 @@ impl ToolCallPipeline { } } - /// Append a streamed tool-call text fragment to the internal buffer. pub fn feed(&mut self, fragment: &str) { self.buffer.append(fragment); } - /// Parse + validate the accumulated buffer, then clear it. - /// - /// Always returns one of `Resolved`, `ParseFailed`, or - /// `ValidationFailed`. `Pending` is reserved for `try_partial`. pub fn finalize(&mut self) -> ToolCallEvent { let input = self.buffer.take(); if input.is_empty() { @@ -55,10 +37,6 @@ impl ToolCallPipeline { } } - /// Peek at the buffer with a partial-tolerant parse. Does NOT clear the - /// buffer. Useful for emitting an intermediate "we have a name" event - /// once enough text is buffered, while still letting the final - /// `finalize` produce the canonical resolved event. #[must_use] pub fn try_partial(&self) -> ToolCallEvent { let input = self.buffer.as_str(); @@ -78,35 +56,33 @@ impl ToolCallPipeline { } #[must_use] - pub fn buffer_is_empty(&self) -> bool { + pub const fn buffer_is_empty(&self) -> bool { self.buffer.is_empty() } - fn validate_resolved( - &self, - tool_calls: Vec, - ) -> ToolCallEvent { - let parsed_wire: Vec = tool_calls + fn validate_resolved(&self, tool_calls: Vec) -> ToolCallEvent { + let parsed_with_ids: Vec = tool_calls .into_iter() - .map(|call| ParsedToolCall::new(call.id, call.name, call.arguments_json)) + .enumerate() + .map(|(index, mut call)| { + if call.id.is_empty() { + call.id = format!("call_{index}"); + } + call + }) .collect(); let mut errors = Vec::new(); - for call in &parsed_wire { + for call in &parsed_with_ids { if let Err(err) = self.validator.validate(call) { errors.push(err); } } if errors.is_empty() { - ToolCallEvent::Resolved(parsed_wire) + ToolCallEvent::Resolved(parsed_with_ids) } else { ToolCallEvent::ValidationFailed(errors) } } } - -// Pipeline composition needs a real LlamaModel to exercise; integration tests -// live under paddler_tests/tests/qwen3_*. The constituent units — -// tool_call_buffer, tool_call_validator, and tool_call_event — each carry -// their own unit tests independent of the model. diff --git a/paddler/src/tool_call_validation_error.rs b/paddler/src/tool_call_validation_error.rs index 0a9c57f4..7f7c9d58 100644 --- a/paddler/src/tool_call_validation_error.rs +++ b/paddler/src/tool_call_validation_error.rs @@ -1,23 +1,7 @@ -/// One specific reason a parsed tool call failed validation. -/// -/// The variants split per failure mode rather than collapsing into a generic -/// `Other(String)` so callers can decide whether to surface the failure to -/// the model (retry-on-validation-failure) or to the client. #[derive(Debug, thiserror::Error)] pub enum ToolCallValidationError { #[error("unknown tool name {0:?}")] UnknownToolName(String), - #[error("arguments for tool {tool_name:?} are not valid JSON: {message}")] - InvalidJson { - tool_name: String, - message: String, - }, - #[error("arguments for tool {tool_name:?} must be a JSON object, got {kind}")] - NotAnObject { - tool_name: String, - /// JSON value kind word (`array`, `string`, `number`, etc.). - kind: &'static str, - }, #[error("arguments for tool {tool_name:?} failed schema check: {message}")] SchemaMismatch { tool_name: String, diff --git a/paddler/src/tool_call_validator.rs b/paddler/src/tool_call_validator.rs index f79ecd5d..009c6f69 100644 --- a/paddler/src/tool_call_validator.rs +++ b/paddler/src/tool_call_validator.rs @@ -2,16 +2,13 @@ use std::collections::HashMap; use jsonschema::Validator; use paddler_types::parsed_tool_call::ParsedToolCall; +use paddler_types::parsed_tool_call::ToolCallArguments; use paddler_types::request_params::continue_from_conversation_history_params::tool::Tool; use paddler_types::request_params::continue_from_conversation_history_params::tool::tool_params::function_call::parameters::Parameters; use paddler_types::request_params::continue_from_conversation_history_params::tool::tool_params::function_call::parameters_schema::validated_parameters_schema::ValidatedParametersSchema; -use serde_json::Value; use crate::tool_call_validation_error::ToolCallValidationError; -/// Why building the validator failed. Either we couldn't render the supplied -/// parameters schema as JSON or the JSON we got back wasn't a valid -/// JSON-Schema document. #[derive(Debug, thiserror::Error)] pub enum ValidatorBuildError { #[error("could not serialize tool {tool_name:?} parameters to JSON: {message}")] @@ -20,36 +17,16 @@ pub enum ValidatorBuildError { InvalidSchema { tool_name: String, message: String }, } -/// Per-tool validation strategy. -/// -/// `JsonObjectOnly` is the fallback when the request didn't supply a JSON -/// schema for the tool's parameters — we still want to confirm the parser -/// returned a JSON object and not a stray scalar/array. `Schema` runs the -/// full `jsonschema::Validator`. enum ValidationStrategy { JsonObjectOnly, Schema(Box), } -/// Validates [`ParsedToolCall`] payloads coming out of the bindings parser. -/// -/// Single responsibility: given a parsed tool call, decide whether the -/// arguments are well-formed under the request's tool definitions. The -/// validator is **always** consulted by the pipeline — when no schema was -/// declared for a tool the strategy defaults to a JSON-object structural -/// check rather than skipping validation entirely. pub struct ToolCallValidator { strategies: HashMap, } impl ToolCallValidator { - /// Build a validator from the request's tools array. Tools with - /// `Parameters::Empty` get the `JsonObjectOnly` fallback; tools with - /// `Parameters::Schema(...)` get a compiled `jsonschema::Validator`. - /// - /// # Errors - /// Returns the underlying `jsonschema` error when a declared schema is - /// itself invalid — this is a request-time validation failure. pub fn from_tools( tools: &[Tool], ) -> Result { @@ -84,33 +61,23 @@ impl ToolCallValidator { Ok(Self { strategies }) } - /// Validate the parsed tool call against its tool's strategy. Returns - /// `Ok(())` on success and a specific [`ToolCallValidationError`] - /// variant on failure. pub fn validate(&self, parsed: &ParsedToolCall) -> Result<(), ToolCallValidationError> { let strategy = self.strategies.get(&parsed.name).ok_or_else(|| { ToolCallValidationError::UnknownToolName(parsed.name.clone()) })?; - let arguments_value: Value = serde_json::from_str(&parsed.arguments_json).map_err(|err| { - ToolCallValidationError::InvalidJson { - tool_name: parsed.name.clone(), - message: err.to_string(), - } - })?; - - if !arguments_value.is_object() { - return Err(ToolCallValidationError::NotAnObject { - tool_name: parsed.name.clone(), - kind: json_value_kind(&arguments_value), - }); - } + let arguments_value = match &parsed.arguments { + ToolCallArguments::ValidJson(value) => value, + ToolCallArguments::InvalidJson(_) => return Ok(()), + }; match strategy { ValidationStrategy::JsonObjectOnly => Ok(()), ValidationStrategy::Schema(validator) => { - let mut messages: Vec = - validator.iter_errors(&arguments_value).map(|err| err.to_string()).collect(); + let mut messages: Vec = validator + .iter_errors(arguments_value) + .map(|err| err.to_string()) + .collect(); if messages.is_empty() { Ok(()) @@ -130,20 +97,12 @@ impl ToolCallValidator { } } -const fn json_value_kind(value: &Value) -> &'static str { - match value { - Value::Null => "null", - Value::Bool(_) => "bool", - Value::Number(_) => "number", - Value::String(_) => "string", - Value::Array(_) => "array", - Value::Object(_) => "object", - } -} - #[cfg(test)] mod tests { + use anyhow::Result; + use anyhow::bail; use paddler_types::parsed_tool_call::ParsedToolCall; + use paddler_types::parsed_tool_call::ToolCallArguments; use paddler_types::request_params::continue_from_conversation_history_params::tool::Tool; use paddler_types::request_params::continue_from_conversation_history_params::tool::tool_params::FunctionCall; use paddler_types::request_params::continue_from_conversation_history_params::tool::tool_params::function_call::function::Function; @@ -151,11 +110,15 @@ mod tests { use paddler_types::request_params::continue_from_conversation_history_params::tool::tool_params::function_call::parameters_schema::validated_parameters_schema::ValidatedParametersSchema; use serde_json::Map; use serde_json::Value; + use serde_json::json; use super::ToolCallValidator; - use super::json_value_kind; use crate::tool_call_validation_error::ToolCallValidationError; + fn valid_json_arguments(value: Value) -> ToolCallArguments { + ToolCallArguments::ValidJson(value) + } + fn weather_tool_with_schema() -> Tool { let mut properties = Map::new(); properties.insert( @@ -188,154 +151,127 @@ mod tests { } #[test] - fn schema_validator_accepts_matching_arguments() { - let validator = - ToolCallValidator::from_tools(&[weather_tool_with_schema()]).unwrap(); + fn schema_validator_accepts_matching_arguments() -> Result<()> { + let validator = ToolCallValidator::from_tools(&[weather_tool_with_schema()])?; let parsed = ParsedToolCall::new( "id".to_owned(), "get_weather".to_owned(), - "{\"location\":\"Paris\"}".to_owned(), + valid_json_arguments(json!({"location": "Paris"})), ); - assert!(validator.validate(&parsed).is_ok()); + validator.validate(&parsed)?; + + Ok(()) } #[test] - fn schema_validator_rejects_missing_required_field() { - let validator = - ToolCallValidator::from_tools(&[weather_tool_with_schema()]).unwrap(); + fn schema_validator_rejects_missing_required_field() -> Result<()> { + let validator = ToolCallValidator::from_tools(&[weather_tool_with_schema()])?; let parsed = ParsedToolCall::new( "id".to_owned(), "get_weather".to_owned(), - "{}".to_owned(), + valid_json_arguments(json!({})), ); match validator.validate(&parsed) { Err(ToolCallValidationError::SchemaMismatch { tool_name, .. }) => { assert_eq!(tool_name, "get_weather"); + Ok(()) } - other => panic!("expected SchemaMismatch, got {other:?}"), + other => bail!("expected SchemaMismatch, got {other:?}"), } } #[test] - fn schema_validator_rejects_wrong_type() { - let validator = - ToolCallValidator::from_tools(&[weather_tool_with_schema()]).unwrap(); + fn schema_validator_rejects_wrong_type() -> Result<()> { + let validator = ToolCallValidator::from_tools(&[weather_tool_with_schema()])?; let parsed = ParsedToolCall::new( "id".to_owned(), "get_weather".to_owned(), - "{\"location\":42}".to_owned(), + valid_json_arguments(json!({"location": 42})), ); match validator.validate(&parsed) { - Err(ToolCallValidationError::SchemaMismatch { .. }) => {} - other => panic!("expected SchemaMismatch, got {other:?}"), + Err(ToolCallValidationError::SchemaMismatch { .. }) => Ok(()), + other => bail!("expected SchemaMismatch, got {other:?}"), } } #[test] - fn unknown_tool_name_returns_error() { - let validator = - ToolCallValidator::from_tools(&[weather_tool_with_schema()]).unwrap(); + fn unknown_tool_name_returns_error() -> Result<()> { + let validator = ToolCallValidator::from_tools(&[weather_tool_with_schema()])?; let parsed = ParsedToolCall::new( "id".to_owned(), "set_thermostat".to_owned(), - "{\"value\":21}".to_owned(), + valid_json_arguments(json!({"value": 21})), ); match validator.validate(&parsed) { Err(ToolCallValidationError::UnknownToolName(name)) => { assert_eq!(name, "set_thermostat"); + Ok(()) } - other => panic!("expected UnknownToolName, got {other:?}"), + other => bail!("expected UnknownToolName, got {other:?}"), } } #[test] - fn invalid_json_returns_invalid_json_error() { - let validator = - ToolCallValidator::from_tools(&[weather_tool_with_schema()]).unwrap(); + fn invalid_json_arguments_pass_validation_silently() -> Result<()> { + let validator = ToolCallValidator::from_tools(&[weather_tool_with_schema()])?; let parsed = ParsedToolCall::new( "id".to_owned(), "get_weather".to_owned(), - "not json".to_owned(), + ToolCallArguments::InvalidJson("not json".to_owned()), ); - match validator.validate(&parsed) { - Err(ToolCallValidationError::InvalidJson { tool_name, .. }) => { - assert_eq!(tool_name, "get_weather"); - } - other => panic!("expected InvalidJson, got {other:?}"), - } - } - - #[test] - fn non_object_arguments_return_not_an_object_error() { - let validator = ToolCallValidator::from_tools(&[schemaless_tool()]).unwrap(); - let parsed = ParsedToolCall::new( - "id".to_owned(), - "freeform".to_owned(), - "[1, 2, 3]".to_owned(), - ); + validator.validate(&parsed)?; - match validator.validate(&parsed) { - Err(ToolCallValidationError::NotAnObject { tool_name, kind }) => { - assert_eq!(tool_name, "freeform"); - assert_eq!(kind, "array"); - } - other => panic!("expected NotAnObject, got {other:?}"), - } + Ok(()) } #[test] - fn schemaless_tool_accepts_any_object() { - let validator = ToolCallValidator::from_tools(&[schemaless_tool()]).unwrap(); + fn schemaless_tool_accepts_any_object() -> Result<()> { + let validator = ToolCallValidator::from_tools(&[schemaless_tool()])?; let parsed = ParsedToolCall::new( "id".to_owned(), "freeform".to_owned(), - "{\"x\":1,\"y\":2}".to_owned(), + valid_json_arguments(json!({"x": 1, "y": 2})), ); - assert!(validator.validate(&parsed).is_ok()); + validator.validate(&parsed)?; + + Ok(()) } #[test] - fn known_tool_names_returns_all_registered_names() { + fn known_tool_names_returns_all_registered_names() -> Result<()> { let validator = ToolCallValidator::from_tools(&[ weather_tool_with_schema(), schemaless_tool(), - ]) - .unwrap(); + ])?; let mut names = validator.known_tool_names(); names.sort_unstable(); assert_eq!(names, vec!["freeform", "get_weather"]); - } - #[test] - fn json_value_kind_reports_each_kind() { - assert_eq!(json_value_kind(&Value::Null), "null"); - assert_eq!(json_value_kind(&Value::Bool(true)), "bool"); - assert_eq!(json_value_kind(&Value::Number(serde_json::Number::from(1))), "number"); - assert_eq!(json_value_kind(&Value::String("x".to_owned())), "string"); - assert_eq!(json_value_kind(&Value::Array(vec![])), "array"); - assert_eq!(json_value_kind(&Value::Object(Map::new())), "object"); + Ok(()) } #[test] - fn empty_tools_yields_validator_that_rejects_any_call() { - let validator = ToolCallValidator::from_tools(&[]).unwrap(); + fn empty_tools_yields_validator_that_rejects_any_call() -> Result<()> { + let validator = ToolCallValidator::from_tools(&[])?; let parsed = ParsedToolCall::new( "id".to_owned(), "anything".to_owned(), - "{}".to_owned(), + valid_json_arguments(json!({})), ); assert!(matches!( validator.validate(&parsed), Err(ToolCallValidationError::UnknownToolName(_)) )); + + Ok(()) } } diff --git a/paddler_client_python/paddler_client/inference_message.py b/paddler_client_python/paddler_client/inference_message.py index 3a135978..72597617 100644 --- a/paddler_client_python/paddler_client/inference_message.py +++ b/paddler_client_python/paddler_client/inference_message.py @@ -4,8 +4,10 @@ from dataclasses import dataclass from enum import StrEnum from typing import Any +from typing import cast from paddler_client.embedding import Embedding +from paddler_client.parsed_tool_call import ParsedToolCall class InferenceMessageKind(StrEnum): @@ -25,7 +27,10 @@ class InferenceMessageKind(StrEnum): SAMPLER_ERROR = "sampler_error" SERVER_ERROR = "server_error" TIMEOUT = "timeout" + TOOL_CALL_PARSED = "tool_call_parsed" + TOOL_CALL_PARSE_FAILED = "tool_call_parse_failed" TOOL_CALL_TOKEN = "tool_call_token" + TOOL_CALL_VALIDATION_FAILED = "tool_call_validation_failed" TOO_MANY_BUFFERED_REQUESTS = "too_many_buffered_requests" UNDETERMINABLE_TOKEN = "undeterminable_token" @@ -96,6 +101,7 @@ class InferenceMessage: error_message: str | None = None error_code: int | None = None summary: GenerationSummary | None = None + parsed_tool_calls: list[ParsedToolCall] | None = None @property def is_token(self) -> bool: @@ -221,6 +227,41 @@ def _parse_generated_token_result( summary=GenerationSummary.from_dict(data["Done"]), ) + if "ToolCallParsed" in data: + raw_calls = data["ToolCallParsed"] + if not isinstance(raw_calls, list): + msg = f"ToolCallParsed payload is not a list: {raw_calls}" + raise ValueError(msg) + typed_calls = cast("list[dict[str, Any]]", raw_calls) + parsed_calls: list[ParsedToolCall] = [ + ParsedToolCall.from_dict(call) for call in typed_calls + ] + return InferenceMessage( + request_id=request_id, + kind=InferenceMessageKind.TOOL_CALL_PARSED, + parsed_tool_calls=parsed_calls, + ) + + if "ToolCallParseFailed" in data: + return InferenceMessage( + request_id=request_id, + kind=InferenceMessageKind.TOOL_CALL_PARSE_FAILED, + error_message=str(data["ToolCallParseFailed"]), + ) + + if "ToolCallValidationFailed" in data: + errors = data["ToolCallValidationFailed"] + if not isinstance(errors, list): + msg = f"ToolCallValidationFailed payload is not a list: {errors}" + raise ValueError(msg) + typed_errors = cast("list[object]", errors) + joined_errors: str = "; ".join(str(error) for error in typed_errors) + return InferenceMessage( + request_id=request_id, + kind=InferenceMessageKind.TOOL_CALL_VALIDATION_FAILED, + error_message=joined_errors, + ) + for key, kind in _GENERATED_TOKEN_KINDS.items(): if key in data: return InferenceMessage( diff --git a/paddler_client_python/paddler_client/parsed_tool_call.py b/paddler_client_python/paddler_client/parsed_tool_call.py new file mode 100644 index 00000000..9be74f1e --- /dev/null +++ b/paddler_client_python/paddler_client/parsed_tool_call.py @@ -0,0 +1,28 @@ +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any +from typing import cast + +from paddler_client.tool_call_arguments import ToolCallArguments +from paddler_client.tool_call_arguments import parse_tool_call_arguments + + +@dataclass(frozen=True) +class ParsedToolCall: + id: str + name: str + arguments: ToolCallArguments + + @classmethod + def from_dict(cls, data: dict[str, Any]) -> ParsedToolCall: + arguments_payload = data["arguments"] + if not isinstance(arguments_payload, dict): + msg = f"arguments field must be a dict (tagged enum), got: {arguments_payload!r}" + raise ValueError(msg) + typed_payload = cast("dict[str, Any]", arguments_payload) + return cls( + id=str(data["id"]), + name=str(data["name"]), + arguments=parse_tool_call_arguments(typed_payload), + ) diff --git a/paddler_client_python/paddler_client/tool_call_arguments.py b/paddler_client_python/paddler_client/tool_call_arguments.py new file mode 100644 index 00000000..2199fb31 --- /dev/null +++ b/paddler_client_python/paddler_client/tool_call_arguments.py @@ -0,0 +1,26 @@ +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any + + +@dataclass(frozen=True) +class ValidJson: + value: Any + + +@dataclass(frozen=True) +class InvalidJson: + raw: str + + +ToolCallArguments = ValidJson | InvalidJson + + +def parse_tool_call_arguments(payload: dict[str, Any]) -> ToolCallArguments: + if "ValidJson" in payload: + return ValidJson(payload["ValidJson"]) + if "InvalidJson" in payload: + return InvalidJson(str(payload["InvalidJson"])) + msg = f"Unknown ToolCallArguments shape: {payload}" + raise ValueError(msg) diff --git a/paddler_client_python/tests/test_inference_message.py b/paddler_client_python/tests/test_inference_message.py index 26eeb506..d520fe38 100644 --- a/paddler_client_python/tests/test_inference_message.py +++ b/paddler_client_python/tests/test_inference_message.py @@ -52,6 +52,95 @@ def test_parse_tool_call_token_response() -> None: assert not message.is_terminal +def test_parse_tool_call_parsed_response_carries_structured_calls() -> None: + data = { + "Response": { + "request_id": "req-1", + "response": { + "GeneratedToken": { + "ToolCallParsed": [ + { + "id": "call_42", + "name": "get_weather", + "arguments": {"ValidJson": {"location": "Paris"}}, + }, + ], + }, + }, + }, + } + message = parse_inference_client_message(data) + + assert message.kind == InferenceMessageKind.TOOL_CALL_PARSED + assert message.parsed_tool_calls is not None + assert len(message.parsed_tool_calls) == 1 + assert message.parsed_tool_calls[0].id == "call_42" + assert message.parsed_tool_calls[0].name == "get_weather" + assert not message.is_token + + +def test_parse_tool_call_parse_failed_response_carries_error() -> None: + data = { + "Response": { + "request_id": "req-1", + "response": { + "GeneratedToken": {"ToolCallParseFailed": "syntax error at 12"}, + }, + }, + } + message = parse_inference_client_message(data) + + assert message.kind == InferenceMessageKind.TOOL_CALL_PARSE_FAILED + assert message.error_message == "syntax error at 12" + + +def test_parse_tool_call_validation_failed_response_joins_errors() -> None: + data = { + "Response": { + "request_id": "req-1", + "response": { + "GeneratedToken": { + "ToolCallValidationFailed": [ + "missing field 'location'", + "extra field 'foo'", + ], + }, + }, + }, + } + message = parse_inference_client_message(data) + + assert message.kind == InferenceMessageKind.TOOL_CALL_VALIDATION_FAILED + assert message.error_message == "missing field 'location'; extra field 'foo'" + + +def test_parse_tool_call_parsed_with_non_list_payload_raises() -> None: + data = { + "Response": { + "request_id": "req-1", + "response": {"GeneratedToken": {"ToolCallParsed": "not a list"}}, + }, + } + + with pytest.raises(ValueError, match="ToolCallParsed payload is not a list"): + parse_inference_client_message(data) + + +def test_parse_tool_call_validation_failed_with_non_list_payload_raises() -> None: + data = { + "Response": { + "request_id": "req-1", + "response": {"GeneratedToken": {"ToolCallValidationFailed": "oops"}}, + }, + } + + with pytest.raises( + ValueError, + match="ToolCallValidationFailed payload is not a list", + ): + parse_inference_client_message(data) + + def test_parse_undeterminable_token_response() -> None: data = { "Response": { diff --git a/paddler_client_python/tests/test_parsed_tool_call.py b/paddler_client_python/tests/test_parsed_tool_call.py new file mode 100644 index 00000000..a48f7ee0 --- /dev/null +++ b/paddler_client_python/tests/test_parsed_tool_call.py @@ -0,0 +1,42 @@ +import pytest + +from paddler_client.parsed_tool_call import ParsedToolCall +from paddler_client.tool_call_arguments import InvalidJson +from paddler_client.tool_call_arguments import ValidJson + + +def test_from_dict_with_valid_json_arguments() -> None: + parsed = ParsedToolCall.from_dict( + { + "id": "call_42", + "name": "get_weather", + "arguments": {"ValidJson": {"location": "Paris"}}, + }, + ) + + assert parsed.id == "call_42" + assert parsed.name == "get_weather" + assert parsed.arguments == ValidJson({"location": "Paris"}) + + +def test_from_dict_with_invalid_json_arguments() -> None: + parsed = ParsedToolCall.from_dict( + { + "id": "call_99", + "name": "freeform", + "arguments": {"InvalidJson": "{half a json"}, + }, + ) + + assert parsed.arguments == InvalidJson("{half a json") + + +def test_from_dict_with_non_dict_arguments_raises() -> None: + with pytest.raises(ValueError, match="arguments field must be a dict"): + ParsedToolCall.from_dict( + { + "id": "x", + "name": "y", + "arguments": "not a dict", + }, + ) diff --git a/paddler_client_python/tests/test_tool_call_arguments.py b/paddler_client_python/tests/test_tool_call_arguments.py new file mode 100644 index 00000000..c6189bc8 --- /dev/null +++ b/paddler_client_python/tests/test_tool_call_arguments.py @@ -0,0 +1,28 @@ +import pytest + +from paddler_client.tool_call_arguments import InvalidJson +from paddler_client.tool_call_arguments import ValidJson +from paddler_client.tool_call_arguments import parse_tool_call_arguments + + +def test_parse_valid_json_with_object() -> None: + result = parse_tool_call_arguments({"ValidJson": {"location": "Paris"}}) + + assert result == ValidJson({"location": "Paris"}) + + +def test_parse_valid_json_with_array() -> None: + result = parse_tool_call_arguments({"ValidJson": [1, 2, 3]}) + + assert result == ValidJson([1, 2, 3]) + + +def test_parse_invalid_json_carries_raw_text() -> None: + result = parse_tool_call_arguments({"InvalidJson": "{half a json"}) + + assert result == InvalidJson("{half a json") + + +def test_parse_unknown_shape_raises() -> None: + with pytest.raises(ValueError, match="Unknown ToolCallArguments shape"): + parse_tool_call_arguments({"SomethingElse": "x"}) diff --git a/paddler_tests/src/inference_http_client.rs b/paddler_tests/src/inference_http_client.rs index f39376e5..cf20bee3 100644 --- a/paddler_tests/src/inference_http_client.rs +++ b/paddler_tests/src/inference_http_client.rs @@ -13,6 +13,7 @@ use url::Url; use crate::inference_message_stream::InferenceMessageStream; +#[derive(Clone)] pub struct InferenceHttpClient { http_client: Client, inference_base_url: Url, diff --git a/paddler_tests/tests/agent_openai_chat_completions_non_streaming_returns_text.rs b/paddler_tests/tests/agent_openai_chat_completions_non_streaming_returns_text.rs index 32ccde2b..6982b71a 100644 --- a/paddler_tests/tests/agent_openai_chat_completions_non_streaming_returns_text.rs +++ b/paddler_tests/tests/agent_openai_chat_completions_non_streaming_returns_text.rs @@ -23,8 +23,9 @@ async fn agent_openai_chat_completions_non_streaming_returns_text() -> Result<() .json(&json!({ "model": "test", "messages": [{"role": "user", "content": "Say hello"}], - "max_completion_tokens": 10, + "max_completion_tokens": 200, "stream": false, + "chat_template_kwargs": {"enable_thinking": false}, })) .send() .await diff --git a/paddler_tests/tests/qwen3_internal_endpoint_concurrent_requests_independent_usage.rs b/paddler_tests/tests/qwen3_internal_endpoint_concurrent_requests_independent_usage.rs new file mode 100644 index 00000000..39818fd7 --- /dev/null +++ b/paddler_tests/tests/qwen3_internal_endpoint_concurrent_requests_independent_usage.rs @@ -0,0 +1,77 @@ +#![cfg(feature = "tests_that_use_llms")] + +use anyhow::Result; +use futures_util::future; +use paddler_tests::collect_generated_tokens::collect_generated_tokens; +use paddler_tests::inference_http_client::InferenceHttpClient; +use paddler_tests::start_in_process_cluster_with_qwen3::start_in_process_cluster_with_qwen3; +use paddler_types::conversation_history::ConversationHistory; +use paddler_types::conversation_message::ConversationMessage; +use paddler_types::conversation_message_content::ConversationMessageContent; +use paddler_types::generated_token_result::GeneratedTokenResult; +use paddler_types::generation_summary::GenerationSummary; +use paddler_types::request_params::continue_from_conversation_history_params::ContinueFromConversationHistoryParams; +use reqwest::Client; + +#[serial_test::file_serial(model_load, path => "../target/model_load.lock")] +#[tokio::test(flavor = "multi_thread")] +async fn qwen3_internal_endpoint_concurrent_requests_keep_independent_usage() -> Result<()> { + let cluster = start_in_process_cluster_with_qwen3(2).await?; + + let inference_client = + InferenceHttpClient::new(Client::new(), cluster.addresses.inference_base_url()?); + + let prompts = ["Say hi.", "Count to three."]; + + let futures = prompts.iter().map(|prompt| { + let client = inference_client.clone(); + let prompt = (*prompt).to_owned(); + async move { + let stream = client + .post_continue_from_conversation_history(&ContinueFromConversationHistoryParams { + add_generation_prompt: true, + conversation_history: ConversationHistory::new(vec![ConversationMessage { + content: ConversationMessageContent::Text(prompt), + role: "user".to_owned(), + }]), + enable_thinking: false, + grammar: None, + max_tokens: 30, + tools: vec![], + }) + .await?; + + let collected = collect_generated_tokens(stream).await?; + + let last = collected + .token_results + .last() + .ok_or_else(|| anyhow::anyhow!("no token results received"))?; + match last { + GeneratedTokenResult::Done(summary) => Ok::(*summary), + other => Err(anyhow::anyhow!("last result was not Done: {other:?}")), + } + } + }); + + let summaries: Vec = + future::try_join_all(futures).await?; + + assert_eq!(summaries.len(), 2); + + for summary in &summaries { + assert!(summary.usage.prompt_tokens > 0); + assert!(summary.usage.completion_tokens() > 0); + } + + // The two requests have different prompts and different generations; + // their usage breakdowns must not be byte-identical. + assert_ne!( + summaries[0].usage, summaries[1].usage, + "concurrent requests reported identical usage; counters likely shared" + ); + + cluster.shutdown().await?; + + Ok(()) +} diff --git a/paddler_tests/tests/qwen3_internal_endpoint_emits_tool_call_parsed_event.rs b/paddler_tests/tests/qwen3_internal_endpoint_emits_tool_call_parsed_event.rs new file mode 100644 index 00000000..82e620ca --- /dev/null +++ b/paddler_tests/tests/qwen3_internal_endpoint_emits_tool_call_parsed_event.rs @@ -0,0 +1,104 @@ +#![cfg(feature = "tests_that_use_llms")] + +use anyhow::Result; +use paddler_tests::collect_generated_tokens::collect_generated_tokens; +use paddler_tests::inference_http_client::InferenceHttpClient; +use paddler_tests::start_in_process_cluster_with_qwen3::start_in_process_cluster_with_qwen3; +use paddler_types::conversation_history::ConversationHistory; +use paddler_types::conversation_message::ConversationMessage; +use paddler_types::conversation_message_content::ConversationMessageContent; +use paddler_types::generated_token_result::GeneratedTokenResult; +use paddler_types::request_params::continue_from_conversation_history_params::ContinueFromConversationHistoryParams; +use paddler_types::request_params::continue_from_conversation_history_params::tool::Tool; +use paddler_types::request_params::continue_from_conversation_history_params::tool::tool_params::FunctionCall; +use paddler_types::request_params::continue_from_conversation_history_params::tool::tool_params::function_call::function::Function; +use paddler_types::request_params::continue_from_conversation_history_params::tool::tool_params::function_call::parameters::Parameters; +use paddler_types::request_params::continue_from_conversation_history_params::tool::tool_params::function_call::parameters_schema::validated_parameters_schema::ValidatedParametersSchema; +use reqwest::Client; +use serde_json::Map; +use serde_json::Value; + +#[serial_test::file_serial(model_load, path => "../target/model_load.lock")] +#[tokio::test(flavor = "multi_thread")] +async fn qwen3_internal_endpoint_emits_tool_call_parsed_event() -> Result<()> { + let cluster = start_in_process_cluster_with_qwen3(1).await?; + + let inference_client = + InferenceHttpClient::new(Client::new(), cluster.addresses.inference_base_url()?); + + let mut location_properties = Map::new(); + location_properties.insert( + "location".to_owned(), + serde_json::json!({"type": "string", "description": "The city name"}), + ); + + let stream = inference_client + .post_continue_from_conversation_history(&ContinueFromConversationHistoryParams { + add_generation_prompt: true, + conversation_history: ConversationHistory::new(vec![ConversationMessage { + content: ConversationMessageContent::Text( + "What is the weather in Paris? Use the get_weather tool to find out." + .to_owned(), + ), + role: "user".to_owned(), + }]), + enable_thinking: false, + grammar: None, + max_tokens: 400, + tools: vec![Tool::Function(FunctionCall { + function: Function { + name: "get_weather".to_owned(), + description: "Get the current weather for a location".to_owned(), + parameters: Parameters::Schema(ValidatedParametersSchema { + schema_type: "object".to_owned(), + properties: Some(location_properties), + required: Some(vec!["location".to_owned()]), + additional_properties: Some(Value::Bool(false)), + }), + }, + })], + }) + .await?; + + let collected = collect_generated_tokens(stream).await?; + + let parsed_events: Vec<&Vec> = collected + .token_results + .iter() + .filter_map(|event| match event { + GeneratedTokenResult::ToolCallParsed(parsed) => Some(parsed), + _ => None, + }) + .collect(); + + assert!( + !parsed_events.is_empty(), + "expected at least one ToolCallParsed event; got tokens:\n{}", + collected.text + ); + + let first_call = parsed_events + .iter() + .flat_map(|calls| calls.iter()) + .next() + .ok_or_else(|| anyhow::anyhow!("no parsed tool calls in any event"))?; + + assert_eq!(first_call.name, "get_weather"); + let location = match &first_call.arguments { + paddler_types::parsed_tool_call::ToolCallArguments::ValidJson(value) => { + value.get("location").cloned() + } + paddler_types::parsed_tool_call::ToolCallArguments::InvalidJson(raw) => { + anyhow::bail!("expected valid JSON arguments, got InvalidJson: {raw}"); + } + }; + assert!( + location.is_some(), + "arguments missing location: {:?}", + first_call.arguments + ); + + cluster.shutdown().await?; + + Ok(()) +} diff --git a/paddler_tests/tests/qwen3_internal_endpoint_max_tokens_usage_matches.rs b/paddler_tests/tests/qwen3_internal_endpoint_max_tokens_usage_matches.rs new file mode 100644 index 00000000..8abc56d5 --- /dev/null +++ b/paddler_tests/tests/qwen3_internal_endpoint_max_tokens_usage_matches.rs @@ -0,0 +1,65 @@ +#![cfg(feature = "tests_that_use_llms")] + +use anyhow::Result; +use paddler_tests::collect_generated_tokens::collect_generated_tokens; +use paddler_tests::inference_http_client::InferenceHttpClient; +use paddler_tests::start_in_process_cluster_with_qwen3::start_in_process_cluster_with_qwen3; +use paddler_types::conversation_history::ConversationHistory; +use paddler_types::conversation_message::ConversationMessage; +use paddler_types::conversation_message_content::ConversationMessageContent; +use paddler_types::generated_token_result::GeneratedTokenResult; +use paddler_types::request_params::continue_from_conversation_history_params::ContinueFromConversationHistoryParams; +use reqwest::Client; + +const MAX_TOKENS: i32 = 20; + +#[serial_test::file_serial(model_load, path => "../target/model_load.lock")] +#[tokio::test(flavor = "multi_thread")] +async fn qwen3_internal_endpoint_max_tokens_usage_matches_streamed_count() -> Result<()> { + let cluster = start_in_process_cluster_with_qwen3(1).await?; + + let inference_client = + InferenceHttpClient::new(Client::new(), cluster.addresses.inference_base_url()?); + + let stream = inference_client + .post_continue_from_conversation_history(&ContinueFromConversationHistoryParams { + add_generation_prompt: true, + conversation_history: ConversationHistory::new(vec![ConversationMessage { + content: ConversationMessageContent::Text("Tell me a long story.".to_owned()), + role: "user".to_owned(), + }]), + enable_thinking: false, + grammar: None, + max_tokens: MAX_TOKENS, + tools: vec![], + }) + .await?; + + let collected = collect_generated_tokens(stream).await?; + + let streamed_token_count = collected + .token_results + .iter() + .filter(|result| result.is_token()) + .count() as u64; + + let last = collected + .token_results + .last() + .ok_or_else(|| anyhow::anyhow!("no token results received"))?; + let GeneratedTokenResult::Done(summary) = last else { + anyhow::bail!("last result was not Done: {last:?}"); + }; + + assert!(streamed_token_count > 0); + assert!(streamed_token_count <= MAX_TOKENS as u64); + assert_eq!( + summary.usage.completion_tokens(), + streamed_token_count, + "Done.usage.completion_tokens must match the count of streamed token deltas" + ); + + cluster.shutdown().await?; + + Ok(()) +} diff --git a/paddler_tests/tests/qwen3_internal_endpoint_pure_content_usage.rs b/paddler_tests/tests/qwen3_internal_endpoint_pure_content_usage.rs new file mode 100644 index 00000000..5c1a8f62 --- /dev/null +++ b/paddler_tests/tests/qwen3_internal_endpoint_pure_content_usage.rs @@ -0,0 +1,58 @@ +#![cfg(feature = "tests_that_use_llms")] + +use anyhow::Result; +use paddler_tests::collect_generated_tokens::collect_generated_tokens; +use paddler_tests::inference_http_client::InferenceHttpClient; +use paddler_tests::start_in_process_cluster_with_qwen3::start_in_process_cluster_with_qwen3; +use paddler_types::conversation_history::ConversationHistory; +use paddler_types::conversation_message::ConversationMessage; +use paddler_types::conversation_message_content::ConversationMessageContent; +use paddler_types::generated_token_result::GeneratedTokenResult; +use paddler_types::request_params::continue_from_conversation_history_params::ContinueFromConversationHistoryParams; +use reqwest::Client; + +#[serial_test::file_serial(model_load, path => "../target/model_load.lock")] +#[tokio::test(flavor = "multi_thread")] +async fn qwen3_internal_endpoint_pure_content_usage_breakdown() -> Result<()> { + let cluster = start_in_process_cluster_with_qwen3(1).await?; + + let inference_client = + InferenceHttpClient::new(Client::new(), cluster.addresses.inference_base_url()?); + + let stream = inference_client + .post_continue_from_conversation_history(&ContinueFromConversationHistoryParams { + add_generation_prompt: true, + conversation_history: ConversationHistory::new(vec![ConversationMessage { + content: ConversationMessageContent::Text("Say hello.".to_owned()), + role: "user".to_owned(), + }]), + enable_thinking: false, + grammar: None, + max_tokens: 60, + tools: vec![], + }) + .await?; + + let collected = collect_generated_tokens(stream).await?; + + let last = collected + .token_results + .last() + .ok_or_else(|| anyhow::anyhow!("no token results received"))?; + let GeneratedTokenResult::Done(summary) = last else { + anyhow::bail!("last result was not Done: {last:?}"); + }; + + assert!(summary.usage.prompt_tokens > 0); + assert!(summary.usage.content_tokens > 0); + assert_eq!(summary.usage.reasoning_tokens, 0); + assert_eq!(summary.usage.tool_call_tokens, 0); + assert_eq!( + summary.usage.completion_tokens(), + summary.usage.content_tokens + summary.usage.undeterminable_tokens + ); + + cluster.shutdown().await?; + + Ok(()) +} diff --git a/paddler_tests/tests/qwen3_openai_non_streaming_emits_tool_calls_for_function_tool.rs b/paddler_tests/tests/qwen3_openai_non_streaming_emits_tool_calls_for_function_tool.rs index 9d299046..1c732ad5 100644 --- a/paddler_tests/tests/qwen3_openai_non_streaming_emits_tool_calls_for_function_tool.rs +++ b/paddler_tests/tests/qwen3_openai_non_streaming_emits_tool_calls_for_function_tool.rs @@ -47,23 +47,42 @@ async fn qwen3_openai_non_streaming_emits_tool_calls_for_function_tool() -> Resu .and_then(Value::as_array) .ok_or_else(|| anyhow::anyhow!("response missing message.tool_calls: {response}"))?; - assert!( - !tool_calls.is_empty(), - "expected at least one tool call in non-streaming response" + assert_eq!( + tool_calls.len(), + 1, + "expected exactly one structured tool call in non-streaming response (got {})", + tool_calls.len() ); let first_call = &tool_calls[0]; assert_eq!( first_call.pointer("/type").and_then(Value::as_str), - Some("function") + Some("function"), ); + + let id = first_call + .pointer("/id") + .and_then(Value::as_str) + .ok_or_else(|| anyhow::anyhow!("tool call missing id"))?; + assert!(!id.is_empty(), "tool call id must not be empty"); + + let function_name = first_call + .pointer("/function/name") + .and_then(Value::as_str) + .ok_or_else(|| anyhow::anyhow!("tool call missing function.name"))?; + + assert_eq!(function_name, "get_weather"); + + let function_arguments = first_call + .pointer("/function/arguments") + .and_then(Value::as_str) + .ok_or_else(|| anyhow::anyhow!("tool call missing function.arguments"))?; + + let parsed_arguments: Value = serde_json::from_str(function_arguments)?; assert!( - first_call - .pointer("/function/arguments") - .and_then(Value::as_str) - .is_some(), - "tool call missing function.arguments" + parsed_arguments.get("location").is_some(), + "tool-call arguments JSON missing 'location' field: {function_arguments}" ); let finish_reason = response diff --git a/paddler_tests/tests/qwen3_openai_non_streaming_usage_with_tool_calls.rs b/paddler_tests/tests/qwen3_openai_non_streaming_usage_with_tool_calls.rs new file mode 100644 index 00000000..e3a16813 --- /dev/null +++ b/paddler_tests/tests/qwen3_openai_non_streaming_usage_with_tool_calls.rs @@ -0,0 +1,81 @@ +#![cfg(feature = "tests_that_use_llms")] + +use anyhow::Result; +use paddler_tests::openai_chat_completions_client::OpenAIChatCompletionsClient; +use paddler_tests::start_in_process_cluster_with_qwen3::start_in_process_cluster_with_qwen3; +use reqwest::Client; +use serde_json::Value; +use serde_json::json; + +#[serial_test::file_serial(model_load, path => "../target/model_load.lock")] +#[tokio::test(flavor = "multi_thread")] +async fn qwen3_openai_non_streaming_usage_with_tool_calls() -> Result<()> { + let cluster = start_in_process_cluster_with_qwen3(1).await?; + let openai_client = OpenAIChatCompletionsClient::new( + Client::new(), + &cluster.addresses.compat_openai_base_url()?, + )?; + + let response = openai_client + .post_non_streaming(&json!({ + "model": "qwen3-test", + "messages": [{ + "role": "user", + "content": "What is the weather in Paris? Use the get_weather tool." + }], + "max_completion_tokens": 400, + "tools": [{ + "type": "function", + "function": { + "name": "get_weather", + "description": "Get the current weather for a location", + "parameters": { + "type": "object", + "properties": { + "location": {"type": "string"} + }, + "required": ["location"], + "additionalProperties": false + } + } + }] + })) + .await?; + + let tool_calls = response + .pointer("/choices/0/message/tool_calls") + .and_then(Value::as_array) + .ok_or_else(|| anyhow::anyhow!("response missing message.tool_calls: {response}"))?; + assert!(!tool_calls.is_empty()); + + let usage = response + .get("usage") + .ok_or_else(|| anyhow::anyhow!("response missing usage: {response}"))?; + + let prompt_tokens = usage + .get("prompt_tokens") + .and_then(Value::as_u64) + .ok_or_else(|| anyhow::anyhow!("usage.prompt_tokens missing"))?; + let completion_tokens = usage + .get("completion_tokens") + .and_then(Value::as_u64) + .ok_or_else(|| anyhow::anyhow!("usage.completion_tokens missing"))?; + let total_tokens = usage + .get("total_tokens") + .and_then(Value::as_u64) + .ok_or_else(|| anyhow::anyhow!("usage.total_tokens missing"))?; + + assert!(prompt_tokens > 0); + // A request that produced a tool call must have spent tokens generating + // the tool-call payload and any wrapping markers; completion_tokens + // therefore cannot be zero. + assert!( + completion_tokens > 0, + "expected non-zero completion_tokens for a tool-call response (got {completion_tokens})" + ); + assert_eq!(total_tokens, prompt_tokens + completion_tokens); + + cluster.shutdown().await?; + + Ok(()) +} diff --git a/paddler_tests/tests/qwen3_openai_streaming_emits_tool_calls_for_function_tool.rs b/paddler_tests/tests/qwen3_openai_streaming_emits_tool_calls_for_function_tool.rs index 1a19b67f..6d8316ef 100644 --- a/paddler_tests/tests/qwen3_openai_streaming_emits_tool_calls_for_function_tool.rs +++ b/paddler_tests/tests/qwen3_openai_streaming_emits_tool_calls_for_function_tool.rs @@ -43,19 +43,48 @@ async fn qwen3_openai_streaming_emits_tool_calls_for_function_tool() -> Result<( })) .await?; - let tool_call_argument_chunks = chunks + let chunks_with_tool_calls: Vec<&Value> = chunks .iter() .filter(|chunk| { chunk - .pointer("/choices/0/delta/tool_calls/0/function/arguments") - .and_then(Value::as_str) - .is_some() + .pointer("/choices/0/delta/tool_calls") + .and_then(Value::as_array) + .is_some_and(|calls| !calls.is_empty()) }) - .count(); + .collect(); + + assert_eq!( + chunks_with_tool_calls.len(), + 1, + "expected exactly one structured tool-call chunk per call (got {})", + chunks_with_tool_calls.len() + ); + + let structured_chunk = chunks_with_tool_calls[0]; + + let function_name = structured_chunk + .pointer("/choices/0/delta/tool_calls/0/function/name") + .and_then(Value::as_str) + .ok_or_else(|| { + anyhow::anyhow!("structured tool-call chunk missing function.name: {structured_chunk}") + })?; + + assert_eq!(function_name, "get_weather"); + + let function_arguments = structured_chunk + .pointer("/choices/0/delta/tool_calls/0/function/arguments") + .and_then(Value::as_str) + .ok_or_else(|| { + anyhow::anyhow!( + "structured tool-call chunk missing function.arguments: {structured_chunk}" + ) + })?; + + let parsed_arguments: Value = serde_json::from_str(function_arguments)?; assert!( - tool_call_argument_chunks > 0, - "expected at least one streaming chunk with delta.tool_calls function arguments (got {tool_call_argument_chunks})" + parsed_arguments.get("location").is_some(), + "tool-call arguments JSON missing 'location' field: {function_arguments}" ); cluster.shutdown().await?; diff --git a/paddler_tests/tests/qwen3_openai_streaming_usage_breakdown_with_thinking.rs b/paddler_tests/tests/qwen3_openai_streaming_usage_breakdown_with_thinking.rs new file mode 100644 index 00000000..bac1ec76 --- /dev/null +++ b/paddler_tests/tests/qwen3_openai_streaming_usage_breakdown_with_thinking.rs @@ -0,0 +1,59 @@ +#![cfg(feature = "tests_that_use_llms")] + +use anyhow::Result; +use paddler_tests::openai_chat_completions_client::OpenAIChatCompletionsClient; +use paddler_tests::start_in_process_cluster_with_qwen3::start_in_process_cluster_with_qwen3; +use reqwest::Client; +use serde_json::Value; +use serde_json::json; + +#[serial_test::file_serial(model_load, path => "../target/model_load.lock")] +#[tokio::test(flavor = "multi_thread")] +async fn qwen3_openai_streaming_usage_breakdown_with_thinking() -> Result<()> { + let cluster = start_in_process_cluster_with_qwen3(1).await?; + let openai_client = OpenAIChatCompletionsClient::new( + Client::new(), + &cluster.addresses.compat_openai_base_url()?, + )?; + + let chunks = openai_client + .post_streaming(&json!({ + "model": "qwen3-test", + "messages": [{ + "role": "user", + "content": "What is two plus two? Think briefly before answering." + }], + "stream": true, + "stream_options": {"include_usage": true}, + "max_completion_tokens": 200, + "chat_template_kwargs": {"enable_thinking": true} + })) + .await?; + + let usage_chunk = chunks + .iter() + .rev() + .find(|chunk| chunk.get("usage").is_some_and(|usage| !usage.is_null())) + .ok_or_else(|| anyhow::anyhow!("no chunk contained usage information"))?; + + let prompt_tokens = usage_chunk + .pointer("/usage/prompt_tokens") + .and_then(Value::as_u64) + .ok_or_else(|| anyhow::anyhow!("usage chunk missing prompt_tokens"))?; + let completion_tokens = usage_chunk + .pointer("/usage/completion_tokens") + .and_then(Value::as_u64) + .ok_or_else(|| anyhow::anyhow!("usage chunk missing completion_tokens"))?; + let total_tokens = usage_chunk + .pointer("/usage/total_tokens") + .and_then(Value::as_u64) + .ok_or_else(|| anyhow::anyhow!("usage chunk missing total_tokens"))?; + + assert!(prompt_tokens > 0); + assert!(completion_tokens > 0); + assert_eq!(total_tokens, prompt_tokens + completion_tokens); + + cluster.shutdown().await?; + + Ok(()) +} diff --git a/paddler_types/Cargo.toml b/paddler_types/Cargo.toml index 765c9748..61030943 100644 --- a/paddler_types/Cargo.toml +++ b/paddler_types/Cargo.toml @@ -11,6 +11,7 @@ version.workspace = true [dependencies] anyhow = { workspace = true } jsonschema = { workspace = true } +llama-cpp-bindings-types = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } diff --git a/paddler_types/src/generated_token_result.rs b/paddler_types/src/generated_token_result.rs index 04148f24..2e82da77 100644 --- a/paddler_types/src/generated_token_result.rs +++ b/paddler_types/src/generated_token_result.rs @@ -49,18 +49,11 @@ impl GeneratedTokenResult { } } - /// True iff the variant carries a structured tool-call event resolved by - /// the parser (not the raw streamed `ToolCallToken` text). The OpenAI - /// compat layer keys off this to decide between text-streaming and - /// `delta.tool_calls` emission. #[must_use] pub const fn is_tool_call_parsed(&self) -> bool { matches!(self, Self::ToolCallParsed(_)) } - /// True iff the variant signals that tool-call parsing or validation - /// failed. These are informational events — they do **not** terminate - /// the request; the scheduler keeps streaming subsequent content. #[must_use] pub const fn is_tool_call_failure(&self) -> bool { matches!( diff --git a/paddler_types/src/lib.rs b/paddler_types/src/lib.rs index 2aa84e2c..3ce5d701 100644 --- a/paddler_types/src/lib.rs +++ b/paddler_types/src/lib.rs @@ -33,11 +33,19 @@ pub mod kv_cache_dtype; pub mod media_marker; pub mod model_metadata; pub mod normalization; -pub mod parsed_tool_call; pub mod pooling_type; pub mod request_params; pub mod rpc_message; pub mod slot_aggregated_status_snapshot; pub mod streamable_result; -pub mod token_usage; pub mod validates; + +pub mod parsed_tool_call { + pub use llama_cpp_bindings_types::ParsedToolCall; + pub use llama_cpp_bindings_types::ToolCallArguments; +} + +pub mod token_usage { + pub use llama_cpp_bindings_types::TokenUsage; + pub use llama_cpp_bindings_types::TokenUsageError; +} diff --git a/paddler_types/src/parsed_tool_call.rs b/paddler_types/src/parsed_tool_call.rs deleted file mode 100644 index de4d9209..00000000 --- a/paddler_types/src/parsed_tool_call.rs +++ /dev/null @@ -1,64 +0,0 @@ -use serde::Deserialize; -use serde::Serialize; - -/// Wire-format value object for one parsed tool call. -/// -/// Mirrors the bindings' `llama_cpp_bindings::ParsedToolCall` so it can be -/// serialised straight into both Paddler-native SSE streams and OpenAI-compat -/// `delta.tool_calls` chunks. The `arguments_json` field is the raw JSON -/// string emitted by the parser; downstream consumers should validate it -/// against the tool's parameter schema before acting on it. -#[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq, Serialize)] -#[serde(deny_unknown_fields)] -pub struct ParsedToolCall { - pub id: String, - pub name: String, - pub arguments_json: String, -} - -impl ParsedToolCall { - #[must_use] - pub const fn new(id: String, name: String, arguments_json: String) -> Self { - Self { - id, - name, - arguments_json, - } - } -} - -#[cfg(test)] -mod tests { - use super::ParsedToolCall; - - #[test] - fn new_assigns_fields_in_order() { - let parsed = ParsedToolCall::new( - "id-1".to_owned(), - "tool".to_owned(), - "{}".to_owned(), - ); - - assert_eq!(parsed.id, "id-1"); - assert_eq!(parsed.name, "tool"); - assert_eq!(parsed.arguments_json, "{}"); - } - - #[test] - fn default_is_empty_strings() { - let parsed = ParsedToolCall::default(); - - assert!(parsed.id.is_empty()); - assert!(parsed.name.is_empty()); - assert!(parsed.arguments_json.is_empty()); - } - - #[test] - fn rejects_unknown_fields_during_deserialization() { - let json = - "{\"id\":\"x\",\"name\":\"y\",\"arguments_json\":\"{}\",\"extra\":\"nope\"}"; - let result = serde_json::from_str::(json); - - assert!(result.is_err()); - } -} diff --git a/paddler_types/src/token_usage.rs b/paddler_types/src/token_usage.rs deleted file mode 100644 index 0a20f143..00000000 --- a/paddler_types/src/token_usage.rs +++ /dev/null @@ -1,30 +0,0 @@ -use serde::Deserialize; -use serde::Serialize; - -#[derive(Clone, Copy, Debug, Default, Deserialize, Serialize)] -#[serde(deny_unknown_fields)] -pub struct TokenUsage { - pub prompt_tokens: u64, - pub cached_prompt_tokens: u64, - pub input_image_tokens: u64, - pub input_audio_tokens: u64, - pub content_tokens: u64, - pub reasoning_tokens: u64, - pub tool_call_tokens: u64, - pub undeterminable_tokens: u64, -} - -impl TokenUsage { - #[must_use] - pub const fn completion_tokens(&self) -> u64 { - self.content_tokens - + self.reasoning_tokens - + self.tool_call_tokens - + self.undeterminable_tokens - } - - #[must_use] - pub const fn total_tokens(&self) -> u64 { - self.prompt_tokens + self.completion_tokens() - } -} From b1807836e5e3b093b267aad6dde20a3456e3f895 Mon Sep 17 00:00:00 2001 From: Mateusz Charytoniuk Date: Tue, 5 May 2026 22:34:53 +0200 Subject: [PATCH 11/51] Decompose scheduler iteration into named pipeline phases --- .../advance_generating_phase.rs | 163 +++++++ .../advance_outcome.rs | 35 ++ .../assemble_batch_phase.rs | 122 ++++++ .../continuous_batch_scheduler/batch_pass.rs | 25 ++ .../classified_token.rs | 7 + .../classify_token_phase.rs | 24 ++ .../commit_phase.rs | 34 ++ .../completion_check_outcome.rs | 5 + .../completion_check_phase.rs | 38 ++ .../contributions.rs | 55 +++ .../decode_batch_phase.rs | 12 + .../decode_outcome.rs | 67 +++ .../emit_token_outcome.rs | 5 + .../emit_token_phase.rs | 46 ++ .../generating_contribution.rs | 4 + .../ingesting_contribution.rs | 6 + .../mod.rs} | 397 ++---------------- .../sample_outcome.rs | 8 + .../sample_token_phase.rs | 32 ++ .../tool_call_phase.rs | 42 ++ 20 files changed, 772 insertions(+), 355 deletions(-) create mode 100644 paddler/src/agent/continuous_batch_scheduler/advance_generating_phase.rs create mode 100644 paddler/src/agent/continuous_batch_scheduler/advance_outcome.rs create mode 100644 paddler/src/agent/continuous_batch_scheduler/assemble_batch_phase.rs create mode 100644 paddler/src/agent/continuous_batch_scheduler/batch_pass.rs create mode 100644 paddler/src/agent/continuous_batch_scheduler/classified_token.rs create mode 100644 paddler/src/agent/continuous_batch_scheduler/classify_token_phase.rs create mode 100644 paddler/src/agent/continuous_batch_scheduler/commit_phase.rs create mode 100644 paddler/src/agent/continuous_batch_scheduler/completion_check_outcome.rs create mode 100644 paddler/src/agent/continuous_batch_scheduler/completion_check_phase.rs create mode 100644 paddler/src/agent/continuous_batch_scheduler/contributions.rs create mode 100644 paddler/src/agent/continuous_batch_scheduler/decode_batch_phase.rs create mode 100644 paddler/src/agent/continuous_batch_scheduler/decode_outcome.rs create mode 100644 paddler/src/agent/continuous_batch_scheduler/emit_token_outcome.rs create mode 100644 paddler/src/agent/continuous_batch_scheduler/emit_token_phase.rs create mode 100644 paddler/src/agent/continuous_batch_scheduler/generating_contribution.rs create mode 100644 paddler/src/agent/continuous_batch_scheduler/ingesting_contribution.rs rename paddler/src/agent/{continuous_batch_scheduler.rs => continuous_batch_scheduler/mod.rs} (71%) create mode 100644 paddler/src/agent/continuous_batch_scheduler/sample_outcome.rs create mode 100644 paddler/src/agent/continuous_batch_scheduler/sample_token_phase.rs create mode 100644 paddler/src/agent/continuous_batch_scheduler/tool_call_phase.rs diff --git a/paddler/src/agent/continuous_batch_scheduler/advance_generating_phase.rs b/paddler/src/agent/continuous_batch_scheduler/advance_generating_phase.rs new file mode 100644 index 00000000..dd5d5993 --- /dev/null +++ b/paddler/src/agent/continuous_batch_scheduler/advance_generating_phase.rs @@ -0,0 +1,163 @@ +use llama_cpp_bindings::context::LlamaContext; +use log::error; +use log::warn; +use paddler_types::generated_token_result::GeneratedTokenResult; +use paddler_types::generation_summary::GenerationSummary; + +use crate::agent::continuous_batch_active_request::ContinuousBatchActiveRequest; +use crate::agent::continuous_batch_request_phase::ContinuousBatchRequestPhase; +use crate::agent::continuous_batch_scheduler::advance_outcome::AdvanceOutcome; +use crate::agent::continuous_batch_scheduler::classify_token_phase::ClassifyTokenPhase; +use crate::agent::continuous_batch_scheduler::completion_check_outcome::CompletionCheckOutcome; +use crate::agent::continuous_batch_scheduler::completion_check_phase::CompletionCheckPhase; +use crate::agent::continuous_batch_scheduler::emit_token_outcome::EmitTokenOutcome; +use crate::agent::continuous_batch_scheduler::emit_token_phase::EmitTokenPhase; +use crate::agent::continuous_batch_scheduler::sample_outcome::SampleOutcome; +use crate::agent::continuous_batch_scheduler::sample_token_phase::SampleTokenPhase; +use crate::agent::continuous_batch_scheduler::tool_call_phase::ToolCallPhase; +use crate::agent::continuous_batch_scheduler_context::ContinuousBatchSchedulerContext; + +pub struct AdvanceGeneratingPhase<'context> { + pub scheduler_context: &'context ContinuousBatchSchedulerContext, + pub llama_context: &'context LlamaContext<'context>, +} + +impl AdvanceGeneratingPhase<'_> { + pub fn run(self, requests: &mut [ContinuousBatchActiveRequest]) { + for request in requests { + let outcome = self.advance_one(request); + self.apply_outcome(request, outcome); + } + } + + fn advance_one(&self, request: &mut ContinuousBatchActiveRequest) -> Option { + if !matches!(request.phase, ContinuousBatchRequestPhase::Generating) { + return None; + } + + if request.pending_sampled_token.is_some() { + return None; + } + + let batch_index = request.i_batch?; + + let raw_token = match (SampleTokenPhase { + context: self.llama_context, + }) + .run(request, batch_index) + { + SampleOutcome::Sampled(token) => token, + SampleOutcome::AllCandidatesEliminated => { + error!( + "{:?}: sequence {} sampling exhausted candidates", + self.scheduler_context.agent_name, request.sequence_id + ); + return Some(AdvanceOutcome::Completed(GeneratedTokenResult::SamplerError( + "all token candidates were eliminated during sampling".to_owned(), + ))); + } + SampleOutcome::GrammarRejected(message) => { + error!( + "{:?}: sequence {} grammar rejected sampled token: {message}", + self.scheduler_context.agent_name, request.sequence_id + ); + return Some(AdvanceOutcome::Completed( + GeneratedTokenResult::GrammarRejectedModelOutput(message), + )); + } + SampleOutcome::Failed(message) => { + error!( + "{:?}: sequence {} sampling error: {message}", + self.scheduler_context.agent_name, request.sequence_id + ); + return Some(AdvanceOutcome::Completed(GeneratedTokenResult::SamplerError( + message, + ))); + } + }; + + let classified = ClassifyTokenPhase.run(request, raw_token); + + let completion_phase = CompletionCheckPhase { + model: &self.scheduler_context.model, + }; + + if matches!( + completion_phase.run(request, &classified.sampled_token), + CompletionCheckOutcome::ReachedEog + ) { + return Some(AdvanceOutcome::Completed(GeneratedTokenResult::Done( + GenerationSummary { + usage: *request.token_classifier.usage(), + }, + ))); + } + + let piece = match (EmitTokenPhase { + model: &self.scheduler_context.model, + }) + .run(request, &classified) + { + EmitTokenOutcome::Emitted(piece) => piece, + EmitTokenOutcome::PieceConversionFailed(message) => { + error!( + "{:?}: sequence {} token_to_piece failed: {message}", + self.scheduler_context.agent_name, request.sequence_id + ); + return Some(AdvanceOutcome::Completed(GeneratedTokenResult::SamplerError( + format!("Failed to convert token to string: {message}"), + ))); + } + EmitTokenOutcome::ChannelDropped => { + warn!( + "{:?}: sequence {} client disconnected (receiver dropped)", + self.scheduler_context.agent_name, request.sequence_id + ); + return Some(AdvanceOutcome::ChannelDropped); + } + }; + + if let Some(event) = ToolCallPhase.run(request, &classified, &piece) + && request.generated_tokens_tx.send(event).is_err() + { + warn!( + "{:?}: sequence {} client disconnected (receiver dropped)", + self.scheduler_context.agent_name, request.sequence_id + ); + return Some(AdvanceOutcome::ChannelDropped); + } + + match completion_phase.run(request, &classified.sampled_token) { + CompletionCheckOutcome::ReachedEog | CompletionCheckOutcome::ReachedMaxTokens => { + Some(AdvanceOutcome::Completed(GeneratedTokenResult::Done( + GenerationSummary { + usage: *request.token_classifier.usage(), + }, + ))) + } + CompletionCheckOutcome::Continue => { + Some(AdvanceOutcome::SampledAndStored(classified.sampled_token)) + } + } + } + + fn apply_outcome( + &self, + request: &mut ContinuousBatchActiveRequest, + outcome: Option, + ) { + match outcome { + None => {} + Some(AdvanceOutcome::SampledAndStored(token)) => { + request.pending_sampled_token = Some(token); + } + Some(AdvanceOutcome::Completed(event)) => { + request.complete_with_outcome(&self.scheduler_context.agent_name, event); + } + Some(AdvanceOutcome::ChannelDropped) => { + request.i_batch = None; + request.phase = ContinuousBatchRequestPhase::Completed; + } + } + } +} diff --git a/paddler/src/agent/continuous_batch_scheduler/advance_outcome.rs b/paddler/src/agent/continuous_batch_scheduler/advance_outcome.rs new file mode 100644 index 00000000..c62dc3d6 --- /dev/null +++ b/paddler/src/agent/continuous_batch_scheduler/advance_outcome.rs @@ -0,0 +1,35 @@ +use llama_cpp_bindings::SampledToken; +use paddler_types::generated_token_result::GeneratedTokenResult; + +pub enum AdvanceOutcome { + SampledAndStored(SampledToken), + Completed(GeneratedTokenResult), + ChannelDropped, +} + +#[cfg(test)] +mod tests { + use paddler_types::generated_token_result::GeneratedTokenResult; + use paddler_types::generation_summary::GenerationSummary; + + use super::AdvanceOutcome; + + #[test] + fn completed_carries_event_through_into_inner() { + let outcome = AdvanceOutcome::Completed(GeneratedTokenResult::Done( + GenerationSummary::default(), + )); + + assert!(matches!( + outcome, + AdvanceOutcome::Completed(GeneratedTokenResult::Done(_)) + )); + } + + #[test] + fn channel_dropped_is_distinct_variant() { + let outcome = AdvanceOutcome::ChannelDropped; + + assert!(matches!(outcome, AdvanceOutcome::ChannelDropped)); + } +} diff --git a/paddler/src/agent/continuous_batch_scheduler/assemble_batch_phase.rs b/paddler/src/agent/continuous_batch_scheduler/assemble_batch_phase.rs new file mode 100644 index 00000000..32b1ab1c --- /dev/null +++ b/paddler/src/agent/continuous_batch_scheduler/assemble_batch_phase.rs @@ -0,0 +1,122 @@ +use anyhow::Result; +use llama_cpp_bindings::SampledToken; + +use crate::agent::continuous_batch_active_request::ContinuousBatchActiveRequest; +use crate::agent::continuous_batch_request_phase::ContinuousBatchRequestPhase; +use crate::agent::continuous_batch_scheduler::batch_pass::BatchPass; +use crate::agent::continuous_batch_scheduler::generating_contribution::GeneratingContribution; +use crate::agent::continuous_batch_scheduler::ingesting_contribution::IngestingContribution; + +pub struct AssembleBatchPhase { + pub batch_n_tokens: usize, +} + +impl AssembleBatchPhase { + /// # Errors + /// Forwards `LlamaBatch::add` failures verbatim. + pub fn run( + &self, + pass: &mut BatchPass, + requests: &mut [ContinuousBatchActiveRequest], + ) -> Result<()> { + let added = self.fill_generating(pass, requests)?; + pass.contributions.current_batch_token_count += added; + self.fill_ingesting(pass, requests)?; + Ok(()) + } + + fn fill_generating( + &self, + pass: &mut BatchPass, + requests: &[ContinuousBatchActiveRequest], + ) -> Result { + let mut tokens_added: usize = 0; + + for (request_index, request) in requests.iter().enumerate() { + if !matches!(request.phase, ContinuousBatchRequestPhase::Generating) { + continue; + } + + let Some(pending_token) = request.pending_sampled_token else { + continue; + }; + + if tokens_added >= self.batch_n_tokens { + break; + } + + let batch_position = pass.batch.n_tokens(); + + pass.batch.add( + &pending_token, + request.current_token_position, + &[request.sequence_id], + true, + )?; + + pass.contributions.generating.push(GeneratingContribution { + request_index, + batch_position, + }); + + tokens_added += 1; + } + + Ok(tokens_added) + } + + #[expect( + clippy::cast_possible_truncation, + clippy::cast_possible_wrap, + reason = "token counts and positions fit in i32 for llama.cpp FFI" + )] + fn fill_ingesting( + &self, + pass: &mut BatchPass, + requests: &[ContinuousBatchActiveRequest], + ) -> Result<()> { + for (request_index, request) in requests.iter().enumerate() { + if !matches!(request.phase, ContinuousBatchRequestPhase::Ingesting) { + continue; + } + + let remaining = request.remaining_prompt_tokens(); + let available_space = self + .batch_n_tokens + .saturating_sub(pass.contributions.current_batch_token_count); + let chunk_size = remaining.len().min(available_space); + + if chunk_size == 0 { + continue; + } + + let chunk = &request.prompt_tokens[request.prompt_tokens_ingested + ..request.prompt_tokens_ingested + chunk_size]; + let is_last_chunk = + request.prompt_tokens_ingested + chunk_size >= request.prompt_tokens.len(); + + for (offset, token) in chunk.iter().enumerate() { + let position = request.current_token_position + offset as i32; + let is_last_token_of_prompt = is_last_chunk && offset == chunk_size - 1; + + pass.batch.add( + &SampledToken::Content(*token), + position, + &[request.sequence_id], + is_last_token_of_prompt, + )?; + } + + pass.contributions.ingesting.push(IngestingContribution { + request_index, + chunk_size, + is_last_chunk, + last_batch_position: pass.batch.n_tokens() - 1, + }); + + pass.contributions.current_batch_token_count += chunk_size; + } + + Ok(()) + } +} diff --git a/paddler/src/agent/continuous_batch_scheduler/batch_pass.rs b/paddler/src/agent/continuous_batch_scheduler/batch_pass.rs new file mode 100644 index 00000000..84ad08e1 --- /dev/null +++ b/paddler/src/agent/continuous_batch_scheduler/batch_pass.rs @@ -0,0 +1,25 @@ +use anyhow::Result; +use llama_cpp_bindings::llama_batch::LlamaBatch; + +use crate::agent::continuous_batch_scheduler::contributions::Contributions; + +pub struct BatchPass<'tokens> { + pub batch: LlamaBatch<'tokens>, + pub contributions: Contributions, +} + +impl BatchPass<'_> { + /// # Errors + /// Forwards [`LlamaBatch::new`] failures verbatim. + pub fn new(batch_n_tokens: usize, max_sequences: i32) -> Result { + Ok(Self { + batch: LlamaBatch::new(batch_n_tokens, max_sequences)?, + contributions: Contributions::default(), + }) + } + + #[must_use] + pub const fn is_empty(&self) -> bool { + self.contributions.is_empty() + } +} diff --git a/paddler/src/agent/continuous_batch_scheduler/classified_token.rs b/paddler/src/agent/continuous_batch_scheduler/classified_token.rs new file mode 100644 index 00000000..85a282ad --- /dev/null +++ b/paddler/src/agent/continuous_batch_scheduler/classified_token.rs @@ -0,0 +1,7 @@ +use llama_cpp_bindings::SampledToken; + +pub struct ClassifiedToken { + pub sampled_token: SampledToken, + pub was_in_tool_call: bool, + pub is_in_tool_call: bool, +} diff --git a/paddler/src/agent/continuous_batch_scheduler/classify_token_phase.rs b/paddler/src/agent/continuous_batch_scheduler/classify_token_phase.rs new file mode 100644 index 00000000..da26307b --- /dev/null +++ b/paddler/src/agent/continuous_batch_scheduler/classify_token_phase.rs @@ -0,0 +1,24 @@ +use llama_cpp_bindings::token::LlamaToken; + +use crate::agent::continuous_batch_active_request::ContinuousBatchActiveRequest; +use crate::agent::continuous_batch_scheduler::classified_token::ClassifiedToken; + +pub struct ClassifyTokenPhase; + +impl ClassifyTokenPhase { + pub fn run( + self, + request: &mut ContinuousBatchActiveRequest, + raw_token: LlamaToken, + ) -> ClassifiedToken { + let was_in_tool_call = request.token_classifier.is_in_tool_call(); + let sampled_token = request.token_classifier.ingest(raw_token); + let is_in_tool_call = request.token_classifier.is_in_tool_call(); + + ClassifiedToken { + sampled_token, + was_in_tool_call, + is_in_tool_call, + } + } +} diff --git a/paddler/src/agent/continuous_batch_scheduler/commit_phase.rs b/paddler/src/agent/continuous_batch_scheduler/commit_phase.rs new file mode 100644 index 00000000..e8c0ca0e --- /dev/null +++ b/paddler/src/agent/continuous_batch_scheduler/commit_phase.rs @@ -0,0 +1,34 @@ +use crate::agent::continuous_batch_active_request::ContinuousBatchActiveRequest; +use crate::agent::continuous_batch_request_phase::ContinuousBatchRequestPhase; +use crate::agent::continuous_batch_scheduler::batch_pass::BatchPass; + +pub struct CommitPhase; + +impl CommitPhase { + #[expect( + clippy::cast_possible_truncation, + clippy::cast_possible_wrap, + reason = "chunk sizes fit in i32 for llama.cpp position arithmetic" + )] + pub fn run(self, pass: BatchPass, requests: &mut [ContinuousBatchActiveRequest]) { + for contribution in pass.contributions.generating { + let request = &mut requests[contribution.request_index]; + + request.pending_sampled_token = None; + request.i_batch = Some(contribution.batch_position); + request.current_token_position += 1; + } + + for contribution in pass.contributions.ingesting { + let request = &mut requests[contribution.request_index]; + + request.prompt_tokens_ingested += contribution.chunk_size; + request.current_token_position += contribution.chunk_size as i32; + + if contribution.is_last_chunk { + request.i_batch = Some(contribution.last_batch_position); + request.phase = ContinuousBatchRequestPhase::Generating; + } + } + } +} diff --git a/paddler/src/agent/continuous_batch_scheduler/completion_check_outcome.rs b/paddler/src/agent/continuous_batch_scheduler/completion_check_outcome.rs new file mode 100644 index 00000000..d3321426 --- /dev/null +++ b/paddler/src/agent/continuous_batch_scheduler/completion_check_outcome.rs @@ -0,0 +1,5 @@ +pub enum CompletionCheckOutcome { + Continue, + ReachedEog, + ReachedMaxTokens, +} diff --git a/paddler/src/agent/continuous_batch_scheduler/completion_check_phase.rs b/paddler/src/agent/continuous_batch_scheduler/completion_check_phase.rs new file mode 100644 index 00000000..75f34aa6 --- /dev/null +++ b/paddler/src/agent/continuous_batch_scheduler/completion_check_phase.rs @@ -0,0 +1,38 @@ +use llama_cpp_bindings::SampledToken; +use llama_cpp_bindings::model::LlamaModel; + +use crate::agent::continuous_batch_active_request::ContinuousBatchActiveRequest; +use crate::agent::continuous_batch_scheduler::completion_check_outcome::CompletionCheckOutcome; + +pub struct CompletionCheckPhase<'model> { + pub model: &'model LlamaModel, +} + +impl CompletionCheckPhase<'_> { + #[must_use] + pub fn run( + &self, + request: &ContinuousBatchActiveRequest, + sampled_token: &SampledToken, + ) -> CompletionCheckOutcome { + if self.model.is_eog_token(sampled_token) { + return CompletionCheckOutcome::ReachedEog; + } + + let usage = request.token_classifier.usage(); + let completion_so_far = + usage.content_tokens + usage.reasoning_tokens + usage.undeterminable_tokens; + + #[expect( + clippy::cast_sign_loss, + reason = "max_tokens is non-negative by API contract" + )] + let max_tokens_u64 = request.max_tokens as u64; + + if completion_so_far >= max_tokens_u64 { + CompletionCheckOutcome::ReachedMaxTokens + } else { + CompletionCheckOutcome::Continue + } + } +} diff --git a/paddler/src/agent/continuous_batch_scheduler/contributions.rs b/paddler/src/agent/continuous_batch_scheduler/contributions.rs new file mode 100644 index 00000000..4ce8805b --- /dev/null +++ b/paddler/src/agent/continuous_batch_scheduler/contributions.rs @@ -0,0 +1,55 @@ +use crate::agent::continuous_batch_scheduler::generating_contribution::GeneratingContribution; +use crate::agent::continuous_batch_scheduler::ingesting_contribution::IngestingContribution; + +#[derive(Default)] +pub struct Contributions { + pub generating: Vec, + pub ingesting: Vec, + pub current_batch_token_count: usize, +} + +impl Contributions { + #[must_use] + pub const fn is_empty(&self) -> bool { + self.generating.is_empty() && self.ingesting.is_empty() + } +} + +#[cfg(test)] +mod tests { + use super::Contributions; + use super::GeneratingContribution; + use super::IngestingContribution; + + #[test] + fn default_contributions_are_empty() { + let contributions = Contributions::default(); + + assert!(contributions.is_empty()); + assert_eq!(contributions.current_batch_token_count, 0); + } + + #[test] + fn contributions_with_generating_entry_is_not_empty() { + let mut contributions = Contributions::default(); + contributions.generating.push(GeneratingContribution { + request_index: 0, + batch_position: 1, + }); + + assert!(!contributions.is_empty()); + } + + #[test] + fn contributions_with_ingesting_entry_is_not_empty() { + let mut contributions = Contributions::default(); + contributions.ingesting.push(IngestingContribution { + request_index: 0, + chunk_size: 4, + is_last_chunk: false, + last_batch_position: 3, + }); + + assert!(!contributions.is_empty()); + } +} diff --git a/paddler/src/agent/continuous_batch_scheduler/decode_batch_phase.rs b/paddler/src/agent/continuous_batch_scheduler/decode_batch_phase.rs new file mode 100644 index 00000000..4393430b --- /dev/null +++ b/paddler/src/agent/continuous_batch_scheduler/decode_batch_phase.rs @@ -0,0 +1,12 @@ +use llama_cpp_bindings::context::LlamaContext; + +use crate::agent::continuous_batch_scheduler::batch_pass::BatchPass; +use crate::agent::continuous_batch_scheduler::decode_outcome::DecodeOutcome; + +pub struct DecodeBatchPhase; + +impl DecodeBatchPhase { + pub fn run(self, pass: &mut BatchPass, context: &mut LlamaContext) -> DecodeOutcome { + DecodeOutcome::from_decode_result(&context.decode(&mut pass.batch)) + } +} diff --git a/paddler/src/agent/continuous_batch_scheduler/decode_outcome.rs b/paddler/src/agent/continuous_batch_scheduler/decode_outcome.rs new file mode 100644 index 00000000..667d2821 --- /dev/null +++ b/paddler/src/agent/continuous_batch_scheduler/decode_outcome.rs @@ -0,0 +1,67 @@ +use llama_cpp_bindings::DecodeError; + +#[derive(Debug)] +pub enum DecodeOutcome { + Decoded, + NeedsEviction, + Aborted, + Errored(i32), +} + +impl DecodeOutcome { + #[must_use] + pub const fn from_decode_result(result: &Result<(), DecodeError>) -> Self { + match result { + Ok(()) => Self::Decoded, + Err(DecodeError::NoKvCacheSlot) => Self::NeedsEviction, + Err(DecodeError::Aborted | DecodeError::NTokensZero) => Self::Aborted, + Err(DecodeError::Unknown(error_code)) => Self::Errored(*error_code), + } + } +} + +#[cfg(test)] +mod tests { + use llama_cpp_bindings::DecodeError; + + use super::DecodeOutcome; + + #[test] + fn ok_maps_to_decoded() { + assert!(matches!( + DecodeOutcome::from_decode_result(&Ok(())), + DecodeOutcome::Decoded + )); + } + + #[test] + fn no_kv_cache_slot_maps_to_needs_eviction() { + assert!(matches!( + DecodeOutcome::from_decode_result(&Err(DecodeError::NoKvCacheSlot)), + DecodeOutcome::NeedsEviction + )); + } + + #[test] + fn aborted_maps_to_aborted() { + assert!(matches!( + DecodeOutcome::from_decode_result(&Err(DecodeError::Aborted)), + DecodeOutcome::Aborted + )); + } + + #[test] + fn n_tokens_zero_maps_to_aborted() { + assert!(matches!( + DecodeOutcome::from_decode_result(&Err(DecodeError::NTokensZero)), + DecodeOutcome::Aborted + )); + } + + #[test] + fn unknown_carries_error_code() { + let outcome = DecodeOutcome::from_decode_result(&Err(DecodeError::Unknown(42))); + + assert!(matches!(outcome, DecodeOutcome::Errored(42))); + } +} diff --git a/paddler/src/agent/continuous_batch_scheduler/emit_token_outcome.rs b/paddler/src/agent/continuous_batch_scheduler/emit_token_outcome.rs new file mode 100644 index 00000000..a871cd8e --- /dev/null +++ b/paddler/src/agent/continuous_batch_scheduler/emit_token_outcome.rs @@ -0,0 +1,5 @@ +pub enum EmitTokenOutcome { + Emitted(String), + PieceConversionFailed(String), + ChannelDropped, +} diff --git a/paddler/src/agent/continuous_batch_scheduler/emit_token_phase.rs b/paddler/src/agent/continuous_batch_scheduler/emit_token_phase.rs new file mode 100644 index 00000000..2460e9dc --- /dev/null +++ b/paddler/src/agent/continuous_batch_scheduler/emit_token_phase.rs @@ -0,0 +1,46 @@ +use llama_cpp_bindings::SampledToken; +use llama_cpp_bindings::model::LlamaModel; +use paddler_types::generated_token_result::GeneratedTokenResult; + +use crate::agent::continuous_batch_active_request::ContinuousBatchActiveRequest; +use crate::agent::continuous_batch_scheduler::classified_token::ClassifiedToken; +use crate::agent::continuous_batch_scheduler::emit_token_outcome::EmitTokenOutcome; + +pub struct EmitTokenPhase<'model> { + pub model: &'model LlamaModel, +} + +impl EmitTokenPhase<'_> { + pub fn run( + &self, + request: &mut ContinuousBatchActiveRequest, + classified: &ClassifiedToken, + ) -> EmitTokenOutcome { + let piece = match self.model.token_to_piece( + &classified.sampled_token, + &mut request.utf8_decoder, + true, + None, + ) { + Ok(piece) => piece, + Err(err) => { + return EmitTokenOutcome::PieceConversionFailed(err.to_string()); + } + }; + + let event = match classified.sampled_token { + SampledToken::Content(_) => GeneratedTokenResult::ContentToken(piece.clone()), + SampledToken::Reasoning(_) => GeneratedTokenResult::ReasoningToken(piece.clone()), + SampledToken::ToolCall(_) => GeneratedTokenResult::ToolCallToken(piece.clone()), + SampledToken::Undeterminable(_) => { + GeneratedTokenResult::UndeterminableToken(piece.clone()) + } + }; + + if request.generated_tokens_tx.send(event).is_err() { + return EmitTokenOutcome::ChannelDropped; + } + + EmitTokenOutcome::Emitted(piece) + } +} diff --git a/paddler/src/agent/continuous_batch_scheduler/generating_contribution.rs b/paddler/src/agent/continuous_batch_scheduler/generating_contribution.rs new file mode 100644 index 00000000..4eeb07fa --- /dev/null +++ b/paddler/src/agent/continuous_batch_scheduler/generating_contribution.rs @@ -0,0 +1,4 @@ +pub struct GeneratingContribution { + pub request_index: usize, + pub batch_position: i32, +} diff --git a/paddler/src/agent/continuous_batch_scheduler/ingesting_contribution.rs b/paddler/src/agent/continuous_batch_scheduler/ingesting_contribution.rs new file mode 100644 index 00000000..48a2b032 --- /dev/null +++ b/paddler/src/agent/continuous_batch_scheduler/ingesting_contribution.rs @@ -0,0 +1,6 @@ +pub struct IngestingContribution { + pub request_index: usize, + pub chunk_size: usize, + pub is_last_chunk: bool, + pub last_batch_position: i32, +} diff --git a/paddler/src/agent/continuous_batch_scheduler.rs b/paddler/src/agent/continuous_batch_scheduler/mod.rs similarity index 71% rename from paddler/src/agent/continuous_batch_scheduler.rs rename to paddler/src/agent/continuous_batch_scheduler/mod.rs index ae6a87a8..788c3365 100644 --- a/paddler/src/agent/continuous_batch_scheduler.rs +++ b/paddler/src/agent/continuous_batch_scheduler/mod.rs @@ -6,10 +6,7 @@ use std::time::Duration; use anyhow::Result; use anyhow::anyhow; -use llama_cpp_bindings::DecodeError; -use llama_cpp_bindings::SampledToken; use llama_cpp_bindings::context::LlamaContext; -use llama_cpp_bindings::llama_batch::LlamaBatch; use llama_cpp_bindings::model::AddBos; use llama_cpp_bindings::mtmd::MtmdBitmap; use llama_cpp_bindings::mtmd::MtmdContext; @@ -44,6 +41,33 @@ use crate::agent::resolve_grammar::resolve_grammar; use crate::agent::sample_token_at_batch_index::sample_token_at_batch_index; use crate::agent::sampling_outcome::SamplingOutcome; use crate::agent::sequence_id_pool::SequenceIdPool; + +pub mod advance_generating_phase; +pub mod advance_outcome; +pub mod assemble_batch_phase; +pub mod batch_pass; +pub mod classified_token; +pub mod classify_token_phase; +pub mod commit_phase; +pub mod completion_check_outcome; +pub mod completion_check_phase; +pub mod contributions; +pub mod decode_batch_phase; +pub mod decode_outcome; +pub mod emit_token_outcome; +pub mod emit_token_phase; +pub mod generating_contribution; +pub mod ingesting_contribution; +pub mod sample_outcome; +pub mod sample_token_phase; +pub mod tool_call_phase; + +use self::advance_generating_phase::AdvanceGeneratingPhase; +use self::assemble_batch_phase::AssembleBatchPhase; +use self::batch_pass::BatchPass; +use self::commit_phase::CommitPhase; +use self::decode_batch_phase::DecodeBatchPhase; +use self::decode_outcome::DecodeOutcome; use crate::decoded_image::DecodedImage; use crate::tool_call_parser::ToolCallParser; use crate::tool_call_pipeline::ToolCallPipeline; @@ -51,18 +75,6 @@ use crate::tool_call_validator::ToolCallValidator; use crate::dispenses_slots::DispensesSlots; use crate::slot_aggregated_status::SlotAggregatedStatus; -struct GeneratingContribution { - request_index: usize, - batch_position: i32, -} - -struct IngestingContribution { - request_index: usize, - chunk_size: usize, - is_last_chunk: bool, - last_batch_position: i32, -} - pub struct ContinuousBatchScheduler { active_requests: Vec, command_rx: Receiver, @@ -884,6 +896,7 @@ impl ContinuousBatchScheduler { self.advance_generating_requests(); let batch_n_tokens = self.scheduler_context.inference_parameters.batch_n_tokens; + let assemble_phase = AssembleBatchPhase { batch_n_tokens }; loop { let max_sequences = self.active_requests.len(); @@ -893,54 +906,38 @@ impl ContinuousBatchScheduler { clippy::cast_possible_wrap, reason = "token counts and positions fit in i32 for llama.cpp FFI" )] - let mut batch = LlamaBatch::new(batch_n_tokens, max_sequences.max(1) as i32)?; - - let mut generating_contributions: Vec = Vec::new(); - let mut ingesting_contributions: Vec = Vec::new(); + let mut pass = BatchPass::new(batch_n_tokens, max_sequences.max(1) as i32)?; - let mut current_batch_token_count: usize = 0; + assemble_phase.run(&mut pass, &mut self.active_requests)?; - current_batch_token_count += self.add_generating_pending_tokens_to_batch( - &mut batch, - batch_n_tokens, - &mut generating_contributions, - )?; - - self.add_ingesting_prompt_chunks_to_batch( - &mut batch, - batch_n_tokens, - current_batch_token_count, - &mut ingesting_contributions, - )?; - - if batch.n_tokens() == 0 { + if pass.is_empty() { return Ok(()); } debug!( "{:?}: decoding batch with {} tokens for {} active requests", self.scheduler_context.agent_name, - batch.n_tokens(), + pass.batch.n_tokens(), self.active_requests.len() ); - match self.llama_context.decode(&mut batch) { - Ok(()) => { - self.commit_contributions(&generating_contributions, &ingesting_contributions); + match DecodeBatchPhase.run(&mut pass, &mut self.llama_context) { + DecodeOutcome::Decoded => { + CommitPhase.run(pass, &mut self.active_requests); return Ok(()); } - Err(DecodeError::NoKvCacheSlot) => { + DecodeOutcome::NeedsEviction => { self.evict_largest_sequence(); if self.active_requests.is_empty() { return Ok(()); } } - Err(DecodeError::Aborted | DecodeError::NTokensZero) => { + DecodeOutcome::Aborted => { return Ok(()); } - Err(DecodeError::Unknown(error_code)) => { + DecodeOutcome::Errored(error_code) => { return Err(anyhow!( "Decode failed with unknown error code: {error_code}" )); @@ -950,321 +947,11 @@ impl ContinuousBatchScheduler { } fn advance_generating_requests(&mut self) { - for active_request in &mut self.active_requests { - if !matches!( - active_request.phase, - ContinuousBatchRequestPhase::Generating - ) { - continue; - } - - if active_request.pending_sampled_token.is_some() { - continue; - } - - let Some(batch_index) = active_request.i_batch else { - continue; - }; - - let raw_token = match sample_token_at_batch_index( - &self.llama_context, - batch_index, - &mut active_request.chain, - &mut active_request.grammar_sampler, - ) { - Ok(SamplingOutcome::Token(raw_token)) => raw_token, - Ok(SamplingOutcome::AllCandidatesEliminated) => { - error!( - "{:?}: sequence {} sampling exhausted candidates", - self.scheduler_context.agent_name, active_request.sequence_id - ); - active_request.complete_with_outcome( - &self.scheduler_context.agent_name, - GeneratedTokenResult::SamplerError( - "all token candidates were eliminated during sampling".to_owned(), - ), - ); - continue; - } - Ok(SamplingOutcome::GrammarRejectedModelOutput(message)) => { - error!( - "{:?}: sequence {} grammar rejected sampled token: {message}", - self.scheduler_context.agent_name, active_request.sequence_id - ); - active_request.complete_with_outcome( - &self.scheduler_context.agent_name, - GeneratedTokenResult::GrammarRejectedModelOutput(message), - ); - continue; - } - Err(err) => { - error!( - "{:?}: sequence {} sampling error: {err:#}", - self.scheduler_context.agent_name, active_request.sequence_id - ); - active_request.complete_with_outcome( - &self.scheduler_context.agent_name, - GeneratedTokenResult::SamplerError(err.to_string()), - ); - continue; - } - }; - - let was_in_tool_call = active_request.token_classifier.is_in_tool_call(); - let sampled_token = active_request.token_classifier.ingest(raw_token); - let is_in_tool_call = active_request.token_classifier.is_in_tool_call(); - - if self.scheduler_context.model.is_eog_token(&sampled_token) { - let summary = GenerationSummary { - usage: *active_request.token_classifier.usage(), - }; - - active_request.complete_with_outcome( - &self.scheduler_context.agent_name, - GeneratedTokenResult::Done(summary), - ); - continue; - } - - let output_string = match self.scheduler_context.model.token_to_piece( - &sampled_token, - &mut active_request.utf8_decoder, - true, - None, - ) { - Ok(output_string) => output_string, - Err(err) => { - error!( - "{:?}: sequence {} token_to_piece failed: {err}", - self.scheduler_context.agent_name, active_request.sequence_id - ); - active_request.complete_with_outcome( - &self.scheduler_context.agent_name, - GeneratedTokenResult::SamplerError(format!( - "Failed to convert token to string: {err}" - )), - ); - continue; - } - }; - - if matches!(sampled_token, SampledToken::ToolCall(_)) - && let Some(pipeline) = active_request.tool_call_pipeline.as_mut() - { - pipeline.feed(&output_string); - } - - let token_event = match sampled_token { - SampledToken::Content(_) => GeneratedTokenResult::ContentToken(output_string), - SampledToken::Reasoning(_) => GeneratedTokenResult::ReasoningToken(output_string), - SampledToken::ToolCall(_) => GeneratedTokenResult::ToolCallToken(output_string), - SampledToken::Undeterminable(_) => { - GeneratedTokenResult::UndeterminableToken(output_string) - } - }; - - if active_request - .generated_tokens_tx - .send(token_event) - .is_err() - { - warn!( - "{:?}: sequence {} client disconnected (receiver dropped)", - self.scheduler_context.agent_name, active_request.sequence_id - ); - - active_request.i_batch = None; - active_request.phase = ContinuousBatchRequestPhase::Completed; - - continue; - } - - if was_in_tool_call - && !is_in_tool_call - && let Some(pipeline) = active_request.tool_call_pipeline.as_mut() - { - let tool_call_event_message = match pipeline.finalize() { - crate::tool_call_event::ToolCallEvent::Resolved(parsed) => { - Some(GeneratedTokenResult::ToolCallParsed(parsed)) - } - crate::tool_call_event::ToolCallEvent::ParseFailed(err) => { - Some(GeneratedTokenResult::ToolCallParseFailed(err.to_string())) - } - crate::tool_call_event::ToolCallEvent::ValidationFailed(errors) => { - Some(GeneratedTokenResult::ToolCallValidationFailed( - errors.into_iter().map(|err| err.to_string()).collect(), - )) - } - crate::tool_call_event::ToolCallEvent::Pending => None, - }; - - if let Some(event) = tool_call_event_message - && active_request.generated_tokens_tx.send(event).is_err() - { - warn!( - "{:?}: sequence {} client disconnected (receiver dropped)", - self.scheduler_context.agent_name, active_request.sequence_id - ); - active_request.i_batch = None; - active_request.phase = ContinuousBatchRequestPhase::Completed; - continue; - } - } - - let usage = active_request.token_classifier.usage(); - let completion_so_far = usage.content_tokens - + usage.reasoning_tokens - + usage.undeterminable_tokens; - #[expect( - clippy::cast_sign_loss, - reason = "max_tokens is non-negative by API contract" - )] - let max_tokens_u64 = active_request.max_tokens as u64; - - if completion_so_far >= max_tokens_u64 { - let summary = GenerationSummary { - usage: *active_request.token_classifier.usage(), - }; - - active_request.complete_with_outcome( - &self.scheduler_context.agent_name, - GeneratedTokenResult::Done(summary), - ); - continue; - } - - active_request.pending_sampled_token = Some(sampled_token); - } - } - - fn add_generating_pending_tokens_to_batch( - &self, - batch: &mut LlamaBatch, - batch_n_tokens: usize, - contributions: &mut Vec, - ) -> Result { - let mut tokens_added: usize = 0; - - for (request_index, active_request) in self.active_requests.iter().enumerate() { - if !matches!( - active_request.phase, - ContinuousBatchRequestPhase::Generating - ) { - continue; - } - - let Some(pending_token) = active_request.pending_sampled_token else { - continue; - }; - - if tokens_added >= batch_n_tokens { - break; - } - - let batch_position = batch.n_tokens(); - - batch.add( - &pending_token, - active_request.current_token_position, - &[active_request.sequence_id], - true, - )?; - - contributions.push(GeneratingContribution { - request_index, - batch_position, - }); - - tokens_added += 1; - } - - Ok(tokens_added) - } - - #[expect( - clippy::cast_possible_truncation, - clippy::cast_possible_wrap, - reason = "token counts and positions fit in i32 for llama.cpp FFI" - )] - fn add_ingesting_prompt_chunks_to_batch( - &self, - batch: &mut LlamaBatch, - batch_n_tokens: usize, - mut current_batch_token_count: usize, - contributions: &mut Vec, - ) -> Result<()> { - for (request_index, active_request) in self.active_requests.iter().enumerate() { - if !matches!(active_request.phase, ContinuousBatchRequestPhase::Ingesting) { - continue; - } - - let remaining = active_request.remaining_prompt_tokens(); - let available_space = batch_n_tokens.saturating_sub(current_batch_token_count); - let chunk_size = remaining.len().min(available_space); - - if chunk_size == 0 { - continue; - } - - let chunk = &active_request.prompt_tokens[active_request.prompt_tokens_ingested - ..active_request.prompt_tokens_ingested + chunk_size]; - let is_last_chunk = active_request.prompt_tokens_ingested + chunk_size - >= active_request.prompt_tokens.len(); - - for (offset, token) in chunk.iter().enumerate() { - let position = active_request.current_token_position + offset as i32; - let is_last_token_of_prompt = is_last_chunk && offset == chunk_size - 1; - - batch.add( - &SampledToken::Content(*token), - position, - &[active_request.sequence_id], - is_last_token_of_prompt, - )?; - } - - contributions.push(IngestingContribution { - request_index, - chunk_size, - is_last_chunk, - last_batch_position: batch.n_tokens() - 1, - }); - - current_batch_token_count += chunk_size; - } - - Ok(()) - } - - #[expect( - clippy::cast_possible_truncation, - clippy::cast_possible_wrap, - reason = "chunk sizes fit in i32 for llama.cpp position arithmetic" - )] - fn commit_contributions( - &mut self, - generating_contributions: &[GeneratingContribution], - ingesting_contributions: &[IngestingContribution], - ) { - for contribution in generating_contributions { - let request = &mut self.active_requests[contribution.request_index]; - - request.pending_sampled_token = None; - request.i_batch = Some(contribution.batch_position); - request.current_token_position += 1; - } - - for contribution in ingesting_contributions { - let request = &mut self.active_requests[contribution.request_index]; - - request.prompt_tokens_ingested += contribution.chunk_size; - request.current_token_position += contribution.chunk_size as i32; - - if contribution.is_last_chunk { - request.i_batch = Some(contribution.last_batch_position); - request.phase = ContinuousBatchRequestPhase::Generating; - } + AdvanceGeneratingPhase { + scheduler_context: &self.scheduler_context, + llama_context: &self.llama_context, } + .run(&mut self.active_requests); } fn evict_largest_sequence(&mut self) { diff --git a/paddler/src/agent/continuous_batch_scheduler/sample_outcome.rs b/paddler/src/agent/continuous_batch_scheduler/sample_outcome.rs new file mode 100644 index 00000000..67a7d951 --- /dev/null +++ b/paddler/src/agent/continuous_batch_scheduler/sample_outcome.rs @@ -0,0 +1,8 @@ +use llama_cpp_bindings::token::LlamaToken; + +pub enum SampleOutcome { + Sampled(LlamaToken), + AllCandidatesEliminated, + GrammarRejected(String), + Failed(String), +} diff --git a/paddler/src/agent/continuous_batch_scheduler/sample_token_phase.rs b/paddler/src/agent/continuous_batch_scheduler/sample_token_phase.rs new file mode 100644 index 00000000..14f9c921 --- /dev/null +++ b/paddler/src/agent/continuous_batch_scheduler/sample_token_phase.rs @@ -0,0 +1,32 @@ +use llama_cpp_bindings::context::LlamaContext; + +use crate::agent::continuous_batch_active_request::ContinuousBatchActiveRequest; +use crate::agent::continuous_batch_scheduler::sample_outcome::SampleOutcome; +use crate::agent::sample_token_at_batch_index::sample_token_at_batch_index; +use crate::agent::sampling_outcome::SamplingOutcome; + +pub struct SampleTokenPhase<'context> { + pub context: &'context LlamaContext<'context>, +} + +impl SampleTokenPhase<'_> { + pub fn run( + &self, + request: &mut ContinuousBatchActiveRequest, + batch_index: i32, + ) -> SampleOutcome { + match sample_token_at_batch_index( + self.context, + batch_index, + &mut request.chain, + &mut request.grammar_sampler, + ) { + Ok(SamplingOutcome::Token(token)) => SampleOutcome::Sampled(token), + Ok(SamplingOutcome::AllCandidatesEliminated) => SampleOutcome::AllCandidatesEliminated, + Ok(SamplingOutcome::GrammarRejectedModelOutput(message)) => { + SampleOutcome::GrammarRejected(message) + } + Err(err) => SampleOutcome::Failed(err.to_string()), + } + } +} diff --git a/paddler/src/agent/continuous_batch_scheduler/tool_call_phase.rs b/paddler/src/agent/continuous_batch_scheduler/tool_call_phase.rs new file mode 100644 index 00000000..050e5036 --- /dev/null +++ b/paddler/src/agent/continuous_batch_scheduler/tool_call_phase.rs @@ -0,0 +1,42 @@ +use llama_cpp_bindings::SampledToken; +use paddler_types::generated_token_result::GeneratedTokenResult; + +use crate::agent::continuous_batch_active_request::ContinuousBatchActiveRequest; +use crate::agent::continuous_batch_scheduler::classified_token::ClassifiedToken; +use crate::tool_call_event::ToolCallEvent; + +pub struct ToolCallPhase; + +impl ToolCallPhase { + pub fn run( + self, + request: &mut ContinuousBatchActiveRequest, + classified: &ClassifiedToken, + piece: &str, + ) -> Option { + if matches!(classified.sampled_token, SampledToken::ToolCall(_)) + && let Some(pipeline) = request.tool_call_pipeline.as_mut() + { + pipeline.feed(piece); + } + + if !classified.was_in_tool_call || classified.is_in_tool_call { + return None; + } + + let pipeline = request.tool_call_pipeline.as_mut()?; + + match pipeline.finalize() { + ToolCallEvent::Resolved(parsed) => Some(GeneratedTokenResult::ToolCallParsed(parsed)), + ToolCallEvent::ParseFailed(err) => { + Some(GeneratedTokenResult::ToolCallParseFailed(err.to_string())) + } + ToolCallEvent::ValidationFailed(errors) => { + Some(GeneratedTokenResult::ToolCallValidationFailed( + errors.into_iter().map(|err| err.to_string()).collect(), + )) + } + ToolCallEvent::Pending => None, + } + } +} From 5817399a566c54f225306cdb2314dc86e5af543d Mon Sep 17 00:00:00 2001 From: Mateusz Charytoniuk Date: Tue, 5 May 2026 22:34:59 +0200 Subject: [PATCH 12/51] Drop impl on ClusterHandle SIGTERMs subprocess children to prevent orphans --- paddler_tests/src/cluster_handle.rs | 48 ++++++++++++++++------------- 1 file changed, 27 insertions(+), 21 deletions(-) diff --git a/paddler_tests/src/cluster_handle.rs b/paddler_tests/src/cluster_handle.rs index 52bf8ccf..7374fadb 100644 --- a/paddler_tests/src/cluster_handle.rs +++ b/paddler_tests/src/cluster_handle.rs @@ -1,4 +1,5 @@ use anyhow::Result; +use log::warn; use paddler_client::PaddlerClient; use tokio_util::sync::CancellationToken; @@ -43,37 +44,25 @@ impl ClusterHandle { } } - pub async fn shutdown(self) -> Result<()> { - let Self { - cancel_token, - completion, - .. - } = self; - - cancel_token.cancel(); + pub async fn shutdown(mut self) -> Result<()> { + self.cancel_token.cancel(); - match completion { - ClusterCompletion::InProcess { - mut agents, - mut balancer, - } => { - for agent_runner in &mut agents { + match &mut self.completion { + ClusterCompletion::InProcess { agents, balancer } => { + for agent_runner in agents.iter_mut() { agent_runner.wait_for_completion().await?; } balancer.wait_for_completion().await?; } - ClusterCompletion::Subprocess { - mut agents, - mut balancer, - } => { - for child in &mut agents { + ClusterCompletion::Subprocess { agents, balancer } => { + for child in agents.iter_mut() { terminate_child(child)?; } - terminate_child(&mut balancer)?; + terminate_child(balancer)?; - for agent in &mut agents { + for agent in agents.iter_mut() { agent.wait().await?; } @@ -84,3 +73,20 @@ impl ClusterHandle { Ok(()) } } + +impl Drop for ClusterHandle { + fn drop(&mut self) { + self.cancel_token.cancel(); + + if let ClusterCompletion::Subprocess { agents, balancer } = &mut self.completion { + for child in agents.iter_mut() { + if let Err(error) = terminate_child(child) { + warn!("ClusterHandle drop: failed to terminate agent subprocess: {error:#}"); + } + } + if let Err(error) = terminate_child(balancer) { + warn!("ClusterHandle drop: failed to terminate balancer subprocess: {error:#}"); + } + } + } +} From cc7e26a12252c87969fb00222483ffa02e138598 Mon Sep 17 00:00:00 2001 From: Mateusz Charytoniuk Date: Wed, 6 May 2026 01:37:53 +0200 Subject: [PATCH 13/51] make tool-call parsing explicit via parse_tool_calls flag and isolate ToolCallPass --- .../advance_generating_phase.rs | 4 +- .../agent/continuous_batch_scheduler/mod.rs | 91 +++++++++++-------- .../tool_call_pass.rs | 91 +++++++++++++++++++ .../tool_call_phase.rs | 42 --------- .../prepare_conversation_history_request.rs | 3 + .../prepared_conversation_history_request.rs | 2 + .../http_route/post_chat_completions.rs | 8 +- paddler/src/tool_call_validator.rs | 35 +++++++ .../paddler_client/inference_message.py | 2 + .../tests/test_inference_message.py | 22 +++++ ...t_conversation_accepts_empty_tools_list.rs | 1 + ...onversation_history_respects_max_tokens.rs | 1 + ...onversation_with_function_tool_succeeds.rs | 1 + ...ion_with_gbnf_grammar_constrains_output.rs | 1 + ..._json_schema_grammar_returns_valid_json.rs | 1 + ..._on_sigterm_during_multimodal_inference.rs | 1 + ...ith_thinking_returns_incompatible_error.rs | 1 + ...l_with_invalid_required_field_in_schema.rs | 1 + ...image_decoding_error_for_invalid_base64.rs | 1 + ...e_decoding_error_for_malformed_data_uri.rs | 1 + ...rns_image_decoding_error_for_remote_url.rs | 1 + ...ens_from_conversation_history_over_http.rs | 1 + ...gent_streams_tokens_from_image_data_uri.rs | 1 + ...ent_text_only_model_rejects_image_input.rs | 1 + ..._drains_in_flight_inference_before_swap.rs | 1 + ...emplate_override_replaces_model_builtin.rs | 1 + ..._template_swaps_between_inference_calls.rs | 1 + ..._conversation_history_requests_complete.rs | 2 + ...h_plain_and_multimodal_run_concurrently.rs | 1 + ...rent_multimodal_requests_produce_tokens.rs | 2 + ...n25vl_generates_tokens_from_image_input.rs | 1 + ..._tokens_for_long_system_and_user_prompt.rs | 1 + ...neration_stops_at_eog_before_max_tokens.rs | 1 + ...ng_mode_stops_cleanly_before_max_tokens.rs | 1 + ...g_multi_turn_conversation_stops_cleanly.rs | 1 + ...with_mmproj_generates_tokens_from_image.rs | 1 + ..._system_message_completes_with_thinking.rs | 1 + ...stem_message_completes_without_thinking.rs | 1 + ...cts_image_with_multimodal_not_supported.rs | 1 + ...erates_tokens_from_conversation_history.rs | 1 + ...ith_thinking_returns_incompatible_error.rs | 1 + ...t_concurrent_requests_independent_usage.rs | 1 + ...l_endpoint_emits_tool_call_parsed_event.rs | 1 + ...nternal_endpoint_emits_tool_call_tokens.rs | 1 + ...ernal_endpoint_max_tokens_usage_matches.rs | 1 + ...n3_internal_endpoint_pure_content_usage.rs | 1 + ...without_parse_flag_emit_only_raw_tokens.rs | 91 +++++++++++++++++++ ...king_disabled_emits_no_reasoning_tokens.rs | 1 + ...thinking_enabled_emits_reasoning_tokens.rs | 1 + ...lvlm2_generates_tokens_from_image_input.rs | 1 + paddler_types/src/generated_token_result.rs | 9 ++ .../mod.rs | 3 + 52 files changed, 359 insertions(+), 85 deletions(-) create mode 100644 paddler/src/agent/continuous_batch_scheduler/tool_call_pass.rs delete mode 100644 paddler/src/agent/continuous_batch_scheduler/tool_call_phase.rs create mode 100644 paddler_tests/tests/qwen3_internal_endpoint_tools_without_parse_flag_emit_only_raw_tokens.rs diff --git a/paddler/src/agent/continuous_batch_scheduler/advance_generating_phase.rs b/paddler/src/agent/continuous_batch_scheduler/advance_generating_phase.rs index dd5d5993..71c400b6 100644 --- a/paddler/src/agent/continuous_batch_scheduler/advance_generating_phase.rs +++ b/paddler/src/agent/continuous_batch_scheduler/advance_generating_phase.rs @@ -14,7 +14,7 @@ use crate::agent::continuous_batch_scheduler::emit_token_outcome::EmitTokenOutco use crate::agent::continuous_batch_scheduler::emit_token_phase::EmitTokenPhase; use crate::agent::continuous_batch_scheduler::sample_outcome::SampleOutcome; use crate::agent::continuous_batch_scheduler::sample_token_phase::SampleTokenPhase; -use crate::agent::continuous_batch_scheduler::tool_call_phase::ToolCallPhase; +use crate::agent::continuous_batch_scheduler::tool_call_pass::ToolCallPass; use crate::agent::continuous_batch_scheduler_context::ContinuousBatchSchedulerContext; pub struct AdvanceGeneratingPhase<'context> { @@ -117,7 +117,7 @@ impl AdvanceGeneratingPhase<'_> { } }; - if let Some(event) = ToolCallPhase.run(request, &classified, &piece) + if let Some(event) = ToolCallPass.run(request.tool_call_pipeline.as_mut(), &classified, &piece) && request.generated_tokens_tx.send(event).is_err() { warn!( diff --git a/paddler/src/agent/continuous_batch_scheduler/mod.rs b/paddler/src/agent/continuous_batch_scheduler/mod.rs index 788c3365..5237ca60 100644 --- a/paddler/src/agent/continuous_batch_scheduler/mod.rs +++ b/paddler/src/agent/continuous_batch_scheduler/mod.rs @@ -60,7 +60,7 @@ pub mod generating_contribution; pub mod ingesting_contribution; pub mod sample_outcome; pub mod sample_token_phase; -pub mod tool_call_phase; +pub mod tool_call_pass; use self::advance_generating_phase::AdvanceGeneratingPhase; use self::assemble_batch_phase::AssembleBatchPhase; @@ -222,12 +222,14 @@ impl ContinuousBatchScheduler { raw_prompt, max_tokens, grammar_sampler, + parse_tool_calls, tools, } => { self.accept_text_prompt( &raw_prompt, max_tokens, grammar_sampler, + parse_tool_calls, tools, generated_tokens_tx, generate_tokens_stop_rx, @@ -238,6 +240,7 @@ impl ContinuousBatchScheduler { images, max_tokens, grammar_sampler, + parse_tool_calls, tools, } => { let multimodal_context = self.scheduler_context.multimodal_context.clone(); @@ -249,6 +252,7 @@ impl ContinuousBatchScheduler { &images, max_tokens, grammar_sampler, + parse_tool_calls, tools, generated_tokens_tx, generate_tokens_stop_rx, @@ -287,6 +291,7 @@ impl ContinuousBatchScheduler { &raw_prompt, max_tokens, grammar_sampler, + false, Vec::new(), generated_tokens_tx, generate_tokens_stop_rx, @@ -351,57 +356,38 @@ impl ContinuousBatchScheduler { fn build_tool_call_pipeline( &self, tools: Vec>, - ) -> Option { - if tools.is_empty() { - return None; + parse_tool_calls: bool, + ) -> Result, GeneratedTokenResult> { + if !parse_tool_calls || tools.is_empty() { + return Ok(None); } - let validator = match ToolCallValidator::from_tools(&tools) { - Ok(validator) => validator, - Err(err) => { - error!( - "{:?}: failed to build tool-call validator (no schema validation): {err:#}", - self.scheduler_context.agent_name - ); - return None; - } - }; + let validator = ToolCallValidator::from_tools(&tools).map_err(|err| { + GeneratedTokenResult::ToolCallValidatorBuildFailed(err.to_string()) + })?; - let tools_json: Vec = match tools + let tools_json: Vec = tools .into_iter() .map(|tool| serde_json::to_value(&tool)) .collect::, _>>() - { - Ok(values) => values, - Err(err) => { - error!( - "{:?}: failed to serialize tools for tool-call parser: {err}", - self.scheduler_context.agent_name - ); - return None; - } - }; + .map_err(|err| GeneratedTokenResult::ToolCallValidatorBuildFailed(err.to_string()))?; - let parser = match ToolCallParser::new(self.scheduler_context.model.clone(), &tools_json) - { - Ok(parser) => parser, - Err(err) => { - error!( - "{:?}: failed to construct tool-call parser: {err}", - self.scheduler_context.agent_name - ); - return None; - } - }; + let parser = ToolCallParser::new(self.scheduler_context.model.clone(), &tools_json) + .map_err(|err| GeneratedTokenResult::ToolCallValidatorBuildFailed(err.to_string()))?; - Some(ToolCallPipeline::new(parser, validator)) + Ok(Some(ToolCallPipeline::new(parser, validator))) } + #[expect( + clippy::too_many_arguments, + reason = "text prompt acceptance genuinely needs all these parameters from the caller" + )] fn accept_text_prompt( &mut self, prompt: &str, max_tokens: i32, grammar_sampler: Option, + parse_tool_calls: bool, tools: Vec>, generated_tokens_tx: mpsc::UnboundedSender, generate_tokens_stop_rx: mpsc::UnboundedReceiver<()>, @@ -522,7 +508,20 @@ impl ContinuousBatchScheduler { prompt_tokens.len() ); - let tool_call_pipeline = self.build_tool_call_pipeline(tools); + let tool_call_pipeline = match self.build_tool_call_pipeline(tools, parse_tool_calls) { + Ok(pipeline) => pipeline, + Err(event) => { + if generated_tokens_tx.send(event).is_err() { + warn!( + "{:?}: failed to send tool-call validator-build error to client (receiver dropped)", + self.scheduler_context.agent_name + ); + } + self.sequence_id_pool.release(sequence_id); + self.slot_aggregated_status.release_slot(); + return; + } + }; self.active_requests.push(ContinuousBatchActiveRequest { chain, @@ -554,6 +553,7 @@ impl ContinuousBatchScheduler { images: &[DecodedImage], max_tokens: i32, grammar_sampler: Option, + parse_tool_calls: bool, tools: Vec>, generated_tokens_tx: mpsc::UnboundedSender, generate_tokens_stop_rx: mpsc::UnboundedReceiver<()>, @@ -750,7 +750,20 @@ impl ContinuousBatchScheduler { self.scheduler_context.agent_name ); - let tool_call_pipeline = self.build_tool_call_pipeline(tools); + let tool_call_pipeline = match self.build_tool_call_pipeline(tools, parse_tool_calls) { + Ok(pipeline) => pipeline, + Err(event) => { + if generated_tokens_tx.send(event).is_err() { + warn!( + "{:?}: failed to send tool-call validator-build error to client (receiver dropped)", + self.scheduler_context.agent_name + ); + } + self.sequence_id_pool.release(sequence_id); + self.slot_aggregated_status.release_slot(); + return; + } + }; self.active_requests.push(ContinuousBatchActiveRequest { chain, diff --git a/paddler/src/agent/continuous_batch_scheduler/tool_call_pass.rs b/paddler/src/agent/continuous_batch_scheduler/tool_call_pass.rs new file mode 100644 index 00000000..04be416e --- /dev/null +++ b/paddler/src/agent/continuous_batch_scheduler/tool_call_pass.rs @@ -0,0 +1,91 @@ +use llama_cpp_bindings::SampledToken; +use paddler_types::generated_token_result::GeneratedTokenResult; + +use crate::agent::continuous_batch_scheduler::classified_token::ClassifiedToken; +use crate::tool_call_event::ToolCallEvent; +use crate::tool_call_pipeline::ToolCallPipeline; + +pub struct ToolCallPass; + +impl ToolCallPass { + #[must_use] + pub fn run( + self, + pipeline: Option<&mut ToolCallPipeline>, + classified: &ClassifiedToken, + piece: &str, + ) -> Option { + let pipeline = pipeline?; + + if matches!(classified.sampled_token, SampledToken::ToolCall(_)) { + pipeline.feed(piece); + } + + if !classified.was_in_tool_call || classified.is_in_tool_call { + return None; + } + + match pipeline.finalize() { + ToolCallEvent::Resolved(parsed) => Some(GeneratedTokenResult::ToolCallParsed(parsed)), + ToolCallEvent::ParseFailed(err) => { + Some(GeneratedTokenResult::ToolCallParseFailed(err.to_string())) + } + ToolCallEvent::ValidationFailed(errors) => { + Some(GeneratedTokenResult::ToolCallValidationFailed( + errors.into_iter().map(|err| err.to_string()).collect(), + )) + } + ToolCallEvent::Pending => None, + } + } +} + +#[cfg(test)] +mod tests { + use llama_cpp_bindings::SampledToken; + use llama_cpp_bindings::token::LlamaToken; + + use super::ToolCallPass; + use crate::agent::continuous_batch_scheduler::classified_token::ClassifiedToken; + + fn classified(was: bool, is: bool, sampled: SampledToken) -> ClassifiedToken { + ClassifiedToken { + sampled_token: sampled, + was_in_tool_call: was, + is_in_tool_call: is, + } + } + + #[test] + fn pipeline_none_returns_none_for_content_token() { + let result = ToolCallPass.run( + None, + &classified(false, false, SampledToken::Content(LlamaToken::new(1))), + "hello", + ); + + assert!(result.is_none()); + } + + #[test] + fn pipeline_none_returns_none_for_tool_call_token() { + let result = ToolCallPass.run( + None, + &classified(true, true, SampledToken::ToolCall(LlamaToken::new(2))), + "{", + ); + + assert!(result.is_none()); + } + + #[test] + fn pipeline_none_returns_none_on_transition_out() { + let result = ToolCallPass.run( + None, + &classified(true, false, SampledToken::ToolCall(LlamaToken::new(3))), + "}", + ); + + assert!(result.is_none()); + } +} diff --git a/paddler/src/agent/continuous_batch_scheduler/tool_call_phase.rs b/paddler/src/agent/continuous_batch_scheduler/tool_call_phase.rs deleted file mode 100644 index 050e5036..00000000 --- a/paddler/src/agent/continuous_batch_scheduler/tool_call_phase.rs +++ /dev/null @@ -1,42 +0,0 @@ -use llama_cpp_bindings::SampledToken; -use paddler_types::generated_token_result::GeneratedTokenResult; - -use crate::agent::continuous_batch_active_request::ContinuousBatchActiveRequest; -use crate::agent::continuous_batch_scheduler::classified_token::ClassifiedToken; -use crate::tool_call_event::ToolCallEvent; - -pub struct ToolCallPhase; - -impl ToolCallPhase { - pub fn run( - self, - request: &mut ContinuousBatchActiveRequest, - classified: &ClassifiedToken, - piece: &str, - ) -> Option { - if matches!(classified.sampled_token, SampledToken::ToolCall(_)) - && let Some(pipeline) = request.tool_call_pipeline.as_mut() - { - pipeline.feed(piece); - } - - if !classified.was_in_tool_call || classified.is_in_tool_call { - return None; - } - - let pipeline = request.tool_call_pipeline.as_mut()?; - - match pipeline.finalize() { - ToolCallEvent::Resolved(parsed) => Some(GeneratedTokenResult::ToolCallParsed(parsed)), - ToolCallEvent::ParseFailed(err) => { - Some(GeneratedTokenResult::ToolCallParseFailed(err.to_string())) - } - ToolCallEvent::ValidationFailed(errors) => { - Some(GeneratedTokenResult::ToolCallValidationFailed( - errors.into_iter().map(|err| err.to_string()).collect(), - )) - } - ToolCallEvent::Pending => None, - } - } -} diff --git a/paddler/src/agent/prepare_conversation_history_request.rs b/paddler/src/agent/prepare_conversation_history_request.rs index 8dad29df..f9470ef2 100644 --- a/paddler/src/agent/prepare_conversation_history_request.rs +++ b/paddler/src/agent/prepare_conversation_history_request.rs @@ -23,6 +23,7 @@ pub fn prepare_conversation_history_request( grammar, conversation_history, max_tokens, + parse_tool_calls, tools, }: ContinueFromConversationHistoryParams, generated_tokens_tx: &mpsc::UnboundedSender, @@ -129,6 +130,7 @@ pub fn prepare_conversation_history_request( images, max_tokens, grammar_sampler, + parse_tool_calls, tools, }); } @@ -137,6 +139,7 @@ pub fn prepare_conversation_history_request( raw_prompt, max_tokens, grammar_sampler, + parse_tool_calls, tools, }) } diff --git a/paddler/src/agent/prepared_conversation_history_request.rs b/paddler/src/agent/prepared_conversation_history_request.rs index dc4f2ae3..61092da5 100644 --- a/paddler/src/agent/prepared_conversation_history_request.rs +++ b/paddler/src/agent/prepared_conversation_history_request.rs @@ -9,6 +9,7 @@ pub enum PreparedConversationHistoryRequest { raw_prompt: String, max_tokens: i32, grammar_sampler: Option, + parse_tool_calls: bool, tools: Vec>, }, MultimodalPrompt { @@ -16,6 +17,7 @@ pub enum PreparedConversationHistoryRequest { images: Vec, max_tokens: i32, grammar_sampler: Option, + parse_tool_calls: bool, tools: Vec>, }, } diff --git a/paddler/src/balancer/compatibility/openai_service/http_route/post_chat_completions.rs b/paddler/src/balancer/compatibility/openai_service/http_route/post_chat_completions.rs index ae3cd1cf..00daea82 100644 --- a/paddler/src/balancer/compatibility/openai_service/http_route/post_chat_completions.rs +++ b/paddler/src/balancer/compatibility/openai_service/http_route/post_chat_completions.rs @@ -361,7 +361,8 @@ impl TransformsOutgoingMessage for OpenAIStreamingResponseTransformer { | GeneratedTokenResult::ImageDecodingFailed(description) | GeneratedTokenResult::MultimodalNotSupported(description) | GeneratedTokenResult::SamplerError(description) - | GeneratedTokenResult::ToolCallParseFailed(description), + | GeneratedTokenResult::ToolCallParseFailed(description) + | GeneratedTokenResult::ToolCallValidatorBuildFailed(description), ), .. }) @@ -568,7 +569,8 @@ impl TransformsOutgoingMessage for OpenAINonStreamingResponseTransformer { | GeneratedTokenResult::ImageDecodingFailed(description) | GeneratedTokenResult::MultimodalNotSupported(description) | GeneratedTokenResult::SamplerError(description) - | GeneratedTokenResult::ToolCallParseFailed(description), + | GeneratedTokenResult::ToolCallParseFailed(description) + | GeneratedTokenResult::ToolCallValidatorBuildFailed(description), ), .. }) @@ -625,6 +627,7 @@ async fn respond( } }; + let parse_tool_calls = !validated_tools.is_empty(); let paddler_params = ContinueFromConversationHistoryParams { add_generation_prompt: true, conversation_history: ConversationHistory::new( @@ -637,6 +640,7 @@ async fn respond( enable_thinking: true, grammar: None, max_tokens: openai_params.max_completion_tokens.unwrap_or(2000), + parse_tool_calls, tools: validated_tools, }; diff --git a/paddler/src/tool_call_validator.rs b/paddler/src/tool_call_validator.rs index 009c6f69..fdbc8ce4 100644 --- a/paddler/src/tool_call_validator.rs +++ b/paddler/src/tool_call_validator.rs @@ -274,4 +274,39 @@ mod tests { Ok(()) } + + fn tool_with_invalid_property_schema() -> Tool { + let mut properties = Map::new(); + properties.insert("location".to_owned(), serde_json::json!({"type": 42})); + + Tool::Function(FunctionCall { + function: Function { + name: "broken_tool".to_owned(), + description: "tool whose property schema is not valid JSON Schema".to_owned(), + parameters: Parameters::Schema(ValidatedParametersSchema { + schema_type: "object".to_owned(), + properties: Some(properties), + required: None, + additional_properties: None, + }), + }, + }) + } + + #[test] + fn invalid_property_schema_rejects_validator_build() -> Result<()> { + let error = ToolCallValidator::from_tools(&[tool_with_invalid_property_schema()]) + .err() + .ok_or_else(|| anyhow::anyhow!("expected ValidatorBuildError, got Ok"))?; + + match error { + super::ValidatorBuildError::InvalidSchema { tool_name, .. } => { + assert_eq!(tool_name, "broken_tool"); + Ok(()) + } + super::ValidatorBuildError::SerializationFailed { .. } => { + bail!("expected InvalidSchema, got SerializationFailed: {error:?}"); + } + } + } } diff --git a/paddler_client_python/paddler_client/inference_message.py b/paddler_client_python/paddler_client/inference_message.py index 72597617..8023a8b6 100644 --- a/paddler_client_python/paddler_client/inference_message.py +++ b/paddler_client_python/paddler_client/inference_message.py @@ -31,6 +31,7 @@ class InferenceMessageKind(StrEnum): TOOL_CALL_PARSE_FAILED = "tool_call_parse_failed" TOOL_CALL_TOKEN = "tool_call_token" TOOL_CALL_VALIDATION_FAILED = "tool_call_validation_failed" + TOOL_CALL_VALIDATOR_BUILD_FAILED = "tool_call_validator_build_failed" TOO_MANY_BUFFERED_REQUESTS = "too_many_buffered_requests" UNDETERMINABLE_TOKEN = "undeterminable_token" @@ -201,6 +202,7 @@ def _parse_response( "ImageDecodingFailed": InferenceMessageKind.IMAGE_DECODING_FAILED, "MultimodalNotSupported": InferenceMessageKind.MULTIMODAL_NOT_SUPPORTED, "SamplerError": InferenceMessageKind.SAMPLER_ERROR, + "ToolCallValidatorBuildFailed": InferenceMessageKind.TOOL_CALL_VALIDATOR_BUILD_FAILED, } diff --git a/paddler_client_python/tests/test_inference_message.py b/paddler_client_python/tests/test_inference_message.py index d520fe38..bb96b02b 100644 --- a/paddler_client_python/tests/test_inference_message.py +++ b/paddler_client_python/tests/test_inference_message.py @@ -315,6 +315,28 @@ def test_parse_grammar_syntax_error() -> None: assert message.is_terminal +def test_parse_tool_call_validator_build_failed_response_carries_error() -> None: + data = { + "Response": { + "request_id": "req-1", + "response": { + "GeneratedToken": { + "ToolCallValidatorBuildFailed": ( + "tool \"get_weather\" parameters are not a valid JSON Schema" + ), + }, + }, + }, + } + message = parse_inference_client_message(data) + + assert message.kind == InferenceMessageKind.TOOL_CALL_VALIDATOR_BUILD_FAILED + assert message.error_message == ( + "tool \"get_weather\" parameters are not a valid JSON Schema" + ) + assert message.is_terminal + + def test_parse_image_decoding_failed() -> None: data = { "Response": { diff --git a/paddler_tests/tests/agent_conversation_accepts_empty_tools_list.rs b/paddler_tests/tests/agent_conversation_accepts_empty_tools_list.rs index 73b2c033..7933b221 100644 --- a/paddler_tests/tests/agent_conversation_accepts_empty_tools_list.rs +++ b/paddler_tests/tests/agent_conversation_accepts_empty_tools_list.rs @@ -31,6 +31,7 @@ async fn agent_conversation_accepts_empty_tools_list() -> Result<()> { enable_thinking: true, grammar: None, max_tokens: 10, + parse_tool_calls: false, tools: vec![], }) .await?; diff --git a/paddler_tests/tests/agent_conversation_history_respects_max_tokens.rs b/paddler_tests/tests/agent_conversation_history_respects_max_tokens.rs index 1058d652..195f3eb1 100644 --- a/paddler_tests/tests/agent_conversation_history_respects_max_tokens.rs +++ b/paddler_tests/tests/agent_conversation_history_respects_max_tokens.rs @@ -31,6 +31,7 @@ async fn agent_conversation_history_respects_max_tokens() -> Result<()> { enable_thinking: false, grammar: None, max_tokens: 20, + parse_tool_calls: false, tools: vec![], }) .await?; diff --git a/paddler_tests/tests/agent_conversation_with_function_tool_succeeds.rs b/paddler_tests/tests/agent_conversation_with_function_tool_succeeds.rs index 5507fd2d..270867e6 100644 --- a/paddler_tests/tests/agent_conversation_with_function_tool_succeeds.rs +++ b/paddler_tests/tests/agent_conversation_with_function_tool_succeeds.rs @@ -45,6 +45,7 @@ async fn agent_conversation_with_function_tool_succeeds() -> Result<()> { enable_thinking: true, grammar: None, max_tokens: 50, + parse_tool_calls: true, tools: vec![Tool::Function(FunctionCall { function: Function { name: "get_weather".to_owned(), diff --git a/paddler_tests/tests/agent_conversation_with_gbnf_grammar_constrains_output.rs b/paddler_tests/tests/agent_conversation_with_gbnf_grammar_constrains_output.rs index c6139515..95e56d1e 100644 --- a/paddler_tests/tests/agent_conversation_with_gbnf_grammar_constrains_output.rs +++ b/paddler_tests/tests/agent_conversation_with_gbnf_grammar_constrains_output.rs @@ -37,6 +37,7 @@ async fn agent_conversation_with_gbnf_grammar_constrains_output() -> Result<()> root: "root".to_owned(), }), max_tokens: 10, + parse_tool_calls: false, tools: vec![], }) .await?; diff --git a/paddler_tests/tests/agent_conversation_with_json_schema_grammar_returns_valid_json.rs b/paddler_tests/tests/agent_conversation_with_json_schema_grammar_returns_valid_json.rs index 25939315..10d9d9d0 100644 --- a/paddler_tests/tests/agent_conversation_with_json_schema_grammar_returns_valid_json.rs +++ b/paddler_tests/tests/agent_conversation_with_json_schema_grammar_returns_valid_json.rs @@ -34,6 +34,7 @@ async fn agent_conversation_with_json_schema_grammar_returns_valid_json() -> Res schema: r#"{"type": "object", "properties": {"answer": {"type": "string"}}, "required": ["answer"]}"#.to_owned(), }), max_tokens: 50, + parse_tool_calls: false, tools: vec![], }) .await?; diff --git a/paddler_tests/tests/agent_exits_cleanly_on_sigterm_during_multimodal_inference.rs b/paddler_tests/tests/agent_exits_cleanly_on_sigterm_during_multimodal_inference.rs index 41da9dac..8064deca 100644 --- a/paddler_tests/tests/agent_exits_cleanly_on_sigterm_during_multimodal_inference.rs +++ b/paddler_tests/tests/agent_exits_cleanly_on_sigterm_during_multimodal_inference.rs @@ -104,6 +104,7 @@ async fn agent_exits_cleanly_on_sigterm_during_multimodal_inference() -> Result< enable_thinking: false, grammar: None, max_tokens: 200, + parse_tool_calls: false, tools: vec![], }) .await?; diff --git a/paddler_tests/tests/agent_grammar_with_thinking_returns_incompatible_error.rs b/paddler_tests/tests/agent_grammar_with_thinking_returns_incompatible_error.rs index 9f29a53a..c1a023b6 100644 --- a/paddler_tests/tests/agent_grammar_with_thinking_returns_incompatible_error.rs +++ b/paddler_tests/tests/agent_grammar_with_thinking_returns_incompatible_error.rs @@ -35,6 +35,7 @@ async fn agent_grammar_with_thinking_returns_incompatible_error() -> Result<()> schema: r#"{"type": "object", "properties": {"answer": {"type": "string"}}, "required": ["answer"]}"#.to_owned(), }), max_tokens: 50, + parse_tool_calls: false, tools: vec![], }) .await; diff --git a/paddler_tests/tests/agent_rejects_tool_with_invalid_required_field_in_schema.rs b/paddler_tests/tests/agent_rejects_tool_with_invalid_required_field_in_schema.rs index 1b6ca8fe..e7cc39fc 100644 --- a/paddler_tests/tests/agent_rejects_tool_with_invalid_required_field_in_schema.rs +++ b/paddler_tests/tests/agent_rejects_tool_with_invalid_required_field_in_schema.rs @@ -40,6 +40,7 @@ async fn agent_rejects_tool_with_invalid_required_field_in_schema() -> Result<() enable_thinking: true, grammar: None, max_tokens: 10, + parse_tool_calls: true, tools: vec![Tool::Function(FunctionCall { function: Function { name: "test_fn".to_owned(), diff --git a/paddler_tests/tests/agent_returns_image_decoding_error_for_invalid_base64.rs b/paddler_tests/tests/agent_returns_image_decoding_error_for_invalid_base64.rs index 2d7121b3..7904a753 100644 --- a/paddler_tests/tests/agent_returns_image_decoding_error_for_invalid_base64.rs +++ b/paddler_tests/tests/agent_returns_image_decoding_error_for_invalid_base64.rs @@ -43,6 +43,7 @@ async fn agent_returns_image_decoding_error_for_invalid_base64() -> Result<()> { enable_thinking: false, grammar: None, max_tokens: 20, + parse_tool_calls: false, tools: vec![], }) .await; diff --git a/paddler_tests/tests/agent_returns_image_decoding_error_for_malformed_data_uri.rs b/paddler_tests/tests/agent_returns_image_decoding_error_for_malformed_data_uri.rs index ffffb651..1b133a1a 100644 --- a/paddler_tests/tests/agent_returns_image_decoding_error_for_malformed_data_uri.rs +++ b/paddler_tests/tests/agent_returns_image_decoding_error_for_malformed_data_uri.rs @@ -43,6 +43,7 @@ async fn agent_returns_image_decoding_error_for_malformed_data_uri() -> Result<( enable_thinking: false, grammar: None, max_tokens: 20, + parse_tool_calls: false, tools: vec![], }) .await; diff --git a/paddler_tests/tests/agent_returns_image_decoding_error_for_remote_url.rs b/paddler_tests/tests/agent_returns_image_decoding_error_for_remote_url.rs index 2dc20b59..e2ef1010 100644 --- a/paddler_tests/tests/agent_returns_image_decoding_error_for_remote_url.rs +++ b/paddler_tests/tests/agent_returns_image_decoding_error_for_remote_url.rs @@ -43,6 +43,7 @@ async fn agent_returns_image_decoding_error_for_remote_url() -> Result<()> { enable_thinking: false, grammar: None, max_tokens: 20, + parse_tool_calls: false, tools: vec![], }) .await; diff --git a/paddler_tests/tests/agent_streams_tokens_from_conversation_history_over_http.rs b/paddler_tests/tests/agent_streams_tokens_from_conversation_history_over_http.rs index 5e0960d8..dd897dc1 100644 --- a/paddler_tests/tests/agent_streams_tokens_from_conversation_history_over_http.rs +++ b/paddler_tests/tests/agent_streams_tokens_from_conversation_history_over_http.rs @@ -31,6 +31,7 @@ async fn agent_streams_tokens_from_conversation_history_over_http() -> Result<() enable_thinking: true, grammar: None, max_tokens: 50, + parse_tool_calls: false, tools: vec![], }) .await?; diff --git a/paddler_tests/tests/agent_streams_tokens_from_image_data_uri.rs b/paddler_tests/tests/agent_streams_tokens_from_image_data_uri.rs index 5aa606f4..ce14ff19 100644 --- a/paddler_tests/tests/agent_streams_tokens_from_image_data_uri.rs +++ b/paddler_tests/tests/agent_streams_tokens_from_image_data_uri.rs @@ -46,6 +46,7 @@ async fn agent_streams_tokens_from_image_data_uri() -> Result<()> { enable_thinking: true, grammar: None, max_tokens: 100, + parse_tool_calls: false, tools: vec![], }) .await?; diff --git a/paddler_tests/tests/agent_text_only_model_rejects_image_input.rs b/paddler_tests/tests/agent_text_only_model_rejects_image_input.rs index bf0f3f4b..e2970c8a 100644 --- a/paddler_tests/tests/agent_text_only_model_rejects_image_input.rs +++ b/paddler_tests/tests/agent_text_only_model_rejects_image_input.rs @@ -46,6 +46,7 @@ async fn agent_text_only_model_rejects_image_input() -> Result<()> { enable_thinking: false, grammar: None, max_tokens: 20, + parse_tool_calls: false, tools: vec![], }) .await; diff --git a/paddler_tests/tests/chat_template_drains_in_flight_inference_before_swap.rs b/paddler_tests/tests/chat_template_drains_in_flight_inference_before_swap.rs index e8a4ccaa..3d5f7585 100644 --- a/paddler_tests/tests/chat_template_drains_in_flight_inference_before_swap.rs +++ b/paddler_tests/tests/chat_template_drains_in_flight_inference_before_swap.rs @@ -75,6 +75,7 @@ async fn chat_template_drains_in_flight_inference_before_swap() -> Result<()> { enable_thinking: false, grammar: None, max_tokens: 10, + parse_tool_calls: false, tools: vec![], }) .await?; diff --git a/paddler_tests/tests/chat_template_override_replaces_model_builtin.rs b/paddler_tests/tests/chat_template_override_replaces_model_builtin.rs index ccc0118d..6d6b3302 100644 --- a/paddler_tests/tests/chat_template_override_replaces_model_builtin.rs +++ b/paddler_tests/tests/chat_template_override_replaces_model_builtin.rs @@ -82,6 +82,7 @@ async fn chat_template_override_replaces_model_builtin() -> Result<()> { enable_thinking: false, grammar: None, max_tokens: 10, + parse_tool_calls: false, tools: vec![], }) .await?; diff --git a/paddler_tests/tests/chat_template_swaps_between_inference_calls.rs b/paddler_tests/tests/chat_template_swaps_between_inference_calls.rs index 08f6e623..7271c3da 100644 --- a/paddler_tests/tests/chat_template_swaps_between_inference_calls.rs +++ b/paddler_tests/tests/chat_template_swaps_between_inference_calls.rs @@ -33,6 +33,7 @@ async fn run_inference_after_template_swap(inference_client: &InferenceHttpClien enable_thinking: false, grammar: None, max_tokens: 10, + parse_tool_calls: false, tools: vec![], }) .await?; diff --git a/paddler_tests/tests/continuous_batch_concurrent_conversation_history_requests_complete.rs b/paddler_tests/tests/continuous_batch_concurrent_conversation_history_requests_complete.rs index 982d8b8a..56a65efb 100644 --- a/paddler_tests/tests/continuous_batch_concurrent_conversation_history_requests_complete.rs +++ b/paddler_tests/tests/continuous_batch_concurrent_conversation_history_requests_complete.rs @@ -33,6 +33,7 @@ async fn continuous_batch_concurrent_conversation_history_requests_complete() -> enable_thinking: false, grammar: None, max_tokens: 20, + parse_tool_calls: false, tools: vec![], }) .await?; @@ -44,6 +45,7 @@ async fn continuous_batch_concurrent_conversation_history_requests_complete() -> enable_thinking: false, grammar: None, max_tokens: 20, + parse_tool_calls: false, tools: vec![], }) .await?; diff --git a/paddler_tests/tests/continuous_batch_plain_and_multimodal_run_concurrently.rs b/paddler_tests/tests/continuous_batch_plain_and_multimodal_run_concurrently.rs index 80d5451a..8f51aa9a 100644 --- a/paddler_tests/tests/continuous_batch_plain_and_multimodal_run_concurrently.rs +++ b/paddler_tests/tests/continuous_batch_plain_and_multimodal_run_concurrently.rs @@ -68,6 +68,7 @@ async fn continuous_batch_plain_and_multimodal_run_concurrently() -> Result<()> enable_thinking: false, grammar: None, max_tokens: 32, + parse_tool_calls: false, tools: vec![], }) .await?; diff --git a/paddler_tests/tests/continuous_batch_two_concurrent_multimodal_requests_produce_tokens.rs b/paddler_tests/tests/continuous_batch_two_concurrent_multimodal_requests_produce_tokens.rs index 0ee99ce5..e3f1433d 100644 --- a/paddler_tests/tests/continuous_batch_two_concurrent_multimodal_requests_produce_tokens.rs +++ b/paddler_tests/tests/continuous_batch_two_concurrent_multimodal_requests_produce_tokens.rs @@ -61,6 +61,7 @@ async fn continuous_batch_two_concurrent_multimodal_requests_produce_tokens() -> enable_thinking: false, grammar: None, max_tokens: 32, + parse_tool_calls: false, tools: vec![], }) .await?; @@ -72,6 +73,7 @@ async fn continuous_batch_two_concurrent_multimodal_requests_produce_tokens() -> enable_thinking: false, grammar: None, max_tokens: 32, + parse_tool_calls: false, tools: vec![], }) .await?; diff --git a/paddler_tests/tests/qwen25vl_generates_tokens_from_image_input.rs b/paddler_tests/tests/qwen25vl_generates_tokens_from_image_input.rs index e6deeef5..c424573d 100644 --- a/paddler_tests/tests/qwen25vl_generates_tokens_from_image_input.rs +++ b/paddler_tests/tests/qwen25vl_generates_tokens_from_image_input.rs @@ -45,6 +45,7 @@ async fn qwen25vl_generates_tokens_from_image_input() -> Result<()> { enable_thinking: false, grammar: None, max_tokens: 200, + parse_tool_calls: false, tools: vec![], }) .await?; diff --git a/paddler_tests/tests/qwen35_generates_tokens_for_long_system_and_user_prompt.rs b/paddler_tests/tests/qwen35_generates_tokens_for_long_system_and_user_prompt.rs index 8f735136..344b1a42 100644 --- a/paddler_tests/tests/qwen35_generates_tokens_for_long_system_and_user_prompt.rs +++ b/paddler_tests/tests/qwen35_generates_tokens_for_long_system_and_user_prompt.rs @@ -81,6 +81,7 @@ async fn qwen35_generates_tokens_for_long_system_and_user_prompt() -> Result<()> enable_thinking: false, grammar: None, max_tokens: 512, + parse_tool_calls: false, tools: vec![], }) .await?; diff --git a/paddler_tests/tests/qwen35_generation_stops_at_eog_before_max_tokens.rs b/paddler_tests/tests/qwen35_generation_stops_at_eog_before_max_tokens.rs index 8fddfdfe..2e34ebae 100644 --- a/paddler_tests/tests/qwen35_generation_stops_at_eog_before_max_tokens.rs +++ b/paddler_tests/tests/qwen35_generation_stops_at_eog_before_max_tokens.rs @@ -29,6 +29,7 @@ async fn qwen35_generation_stops_at_eog_before_max_tokens() -> Result<()> { enable_thinking: false, grammar: None, max_tokens: 500, + parse_tool_calls: false, tools: vec![], }) .await?; diff --git a/paddler_tests/tests/qwen35_thinking_mode_stops_cleanly_before_max_tokens.rs b/paddler_tests/tests/qwen35_thinking_mode_stops_cleanly_before_max_tokens.rs index 25fa1ecb..5abf0142 100644 --- a/paddler_tests/tests/qwen35_thinking_mode_stops_cleanly_before_max_tokens.rs +++ b/paddler_tests/tests/qwen35_thinking_mode_stops_cleanly_before_max_tokens.rs @@ -29,6 +29,7 @@ async fn qwen35_thinking_mode_stops_cleanly_before_max_tokens() -> Result<()> { enable_thinking: true, grammar: None, max_tokens: 2000, + parse_tool_calls: false, tools: vec![], }) .await?; diff --git a/paddler_tests/tests/qwen35_thinking_multi_turn_conversation_stops_cleanly.rs b/paddler_tests/tests/qwen35_thinking_multi_turn_conversation_stops_cleanly.rs index 323cf656..73b74de5 100644 --- a/paddler_tests/tests/qwen35_thinking_multi_turn_conversation_stops_cleanly.rs +++ b/paddler_tests/tests/qwen35_thinking_multi_turn_conversation_stops_cleanly.rs @@ -45,6 +45,7 @@ async fn qwen35_thinking_multi_turn_conversation_stops_cleanly() -> Result<()> { enable_thinking: true, grammar: None, max_tokens: 1000, + parse_tool_calls: false, tools: vec![], }) .await?; diff --git a/paddler_tests/tests/qwen35_with_mmproj_generates_tokens_from_image.rs b/paddler_tests/tests/qwen35_with_mmproj_generates_tokens_from_image.rs index 419226fa..af61b03e 100644 --- a/paddler_tests/tests/qwen35_with_mmproj_generates_tokens_from_image.rs +++ b/paddler_tests/tests/qwen35_with_mmproj_generates_tokens_from_image.rs @@ -45,6 +45,7 @@ async fn qwen35_with_mmproj_generates_tokens_from_image() -> Result<()> { enable_thinking: false, grammar: None, max_tokens: 200, + parse_tool_calls: false, tools: vec![], }) .await?; diff --git a/paddler_tests/tests/qwen35_with_system_message_completes_with_thinking.rs b/paddler_tests/tests/qwen35_with_system_message_completes_with_thinking.rs index 797a1843..141ce1ee 100644 --- a/paddler_tests/tests/qwen35_with_system_message_completes_with_thinking.rs +++ b/paddler_tests/tests/qwen35_with_system_message_completes_with_thinking.rs @@ -41,6 +41,7 @@ async fn qwen35_with_system_message_completes_with_thinking() -> Result<()> { enable_thinking: true, grammar: None, max_tokens: 2000, + parse_tool_calls: false, tools: vec![], }) .await?; diff --git a/paddler_tests/tests/qwen35_with_system_message_completes_without_thinking.rs b/paddler_tests/tests/qwen35_with_system_message_completes_without_thinking.rs index b074ae16..99726031 100644 --- a/paddler_tests/tests/qwen35_with_system_message_completes_without_thinking.rs +++ b/paddler_tests/tests/qwen35_with_system_message_completes_without_thinking.rs @@ -41,6 +41,7 @@ async fn qwen35_with_system_message_completes_without_thinking() -> Result<()> { enable_thinking: false, grammar: None, max_tokens: 512, + parse_tool_calls: false, tools: vec![], }) .await?; diff --git a/paddler_tests/tests/qwen35_without_mmproj_rejects_image_with_multimodal_not_supported.rs b/paddler_tests/tests/qwen35_without_mmproj_rejects_image_with_multimodal_not_supported.rs index fc286aec..616b439f 100644 --- a/paddler_tests/tests/qwen35_without_mmproj_rejects_image_with_multimodal_not_supported.rs +++ b/paddler_tests/tests/qwen35_without_mmproj_rejects_image_with_multimodal_not_supported.rs @@ -45,6 +45,7 @@ async fn qwen35_without_mmproj_rejects_image_with_multimodal_not_supported() -> enable_thinking: false, grammar: None, max_tokens: 100, + parse_tool_calls: false, tools: vec![], }) .await; diff --git a/paddler_tests/tests/qwen3_generates_tokens_from_conversation_history.rs b/paddler_tests/tests/qwen3_generates_tokens_from_conversation_history.rs index fb4b1c61..05ec1561 100644 --- a/paddler_tests/tests/qwen3_generates_tokens_from_conversation_history.rs +++ b/paddler_tests/tests/qwen3_generates_tokens_from_conversation_history.rs @@ -29,6 +29,7 @@ async fn qwen3_generates_tokens_from_conversation_history() -> Result<()> { enable_thinking: false, grammar: None, max_tokens: 500, + parse_tool_calls: false, tools: vec![], }) .await?; diff --git a/paddler_tests/tests/qwen3_grammar_with_thinking_returns_incompatible_error.rs b/paddler_tests/tests/qwen3_grammar_with_thinking_returns_incompatible_error.rs index c829367e..05c48a8f 100644 --- a/paddler_tests/tests/qwen3_grammar_with_thinking_returns_incompatible_error.rs +++ b/paddler_tests/tests/qwen3_grammar_with_thinking_returns_incompatible_error.rs @@ -32,6 +32,7 @@ async fn qwen3_grammar_with_thinking_returns_incompatible_error() -> Result<()> schema: r#"{"type": "object", "properties": {"answer": {"type": "string"}}, "required": ["answer"]}"#.to_owned(), }), max_tokens: 50, + parse_tool_calls: false, tools: vec![], }) .await; diff --git a/paddler_tests/tests/qwen3_internal_endpoint_concurrent_requests_independent_usage.rs b/paddler_tests/tests/qwen3_internal_endpoint_concurrent_requests_independent_usage.rs index 39818fd7..b8a48da3 100644 --- a/paddler_tests/tests/qwen3_internal_endpoint_concurrent_requests_independent_usage.rs +++ b/paddler_tests/tests/qwen3_internal_endpoint_concurrent_requests_independent_usage.rs @@ -37,6 +37,7 @@ async fn qwen3_internal_endpoint_concurrent_requests_keep_independent_usage() -> enable_thinking: false, grammar: None, max_tokens: 30, + parse_tool_calls: false, tools: vec![], }) .await?; diff --git a/paddler_tests/tests/qwen3_internal_endpoint_emits_tool_call_parsed_event.rs b/paddler_tests/tests/qwen3_internal_endpoint_emits_tool_call_parsed_event.rs index 82e620ca..2cdbd70d 100644 --- a/paddler_tests/tests/qwen3_internal_endpoint_emits_tool_call_parsed_event.rs +++ b/paddler_tests/tests/qwen3_internal_endpoint_emits_tool_call_parsed_event.rs @@ -45,6 +45,7 @@ async fn qwen3_internal_endpoint_emits_tool_call_parsed_event() -> Result<()> { enable_thinking: false, grammar: None, max_tokens: 400, + parse_tool_calls: true, tools: vec![Tool::Function(FunctionCall { function: Function { name: "get_weather".to_owned(), diff --git a/paddler_tests/tests/qwen3_internal_endpoint_emits_tool_call_tokens.rs b/paddler_tests/tests/qwen3_internal_endpoint_emits_tool_call_tokens.rs index bb1c3d09..2ddb4826 100644 --- a/paddler_tests/tests/qwen3_internal_endpoint_emits_tool_call_tokens.rs +++ b/paddler_tests/tests/qwen3_internal_endpoint_emits_tool_call_tokens.rs @@ -45,6 +45,7 @@ async fn qwen3_internal_endpoint_emits_tool_call_tokens() -> Result<()> { enable_thinking: false, grammar: None, max_tokens: 400, + parse_tool_calls: true, tools: vec![Tool::Function(FunctionCall { function: Function { name: "get_weather".to_owned(), diff --git a/paddler_tests/tests/qwen3_internal_endpoint_max_tokens_usage_matches.rs b/paddler_tests/tests/qwen3_internal_endpoint_max_tokens_usage_matches.rs index 8abc56d5..6ff3ac3a 100644 --- a/paddler_tests/tests/qwen3_internal_endpoint_max_tokens_usage_matches.rs +++ b/paddler_tests/tests/qwen3_internal_endpoint_max_tokens_usage_matches.rs @@ -31,6 +31,7 @@ async fn qwen3_internal_endpoint_max_tokens_usage_matches_streamed_count() -> Re enable_thinking: false, grammar: None, max_tokens: MAX_TOKENS, + parse_tool_calls: false, tools: vec![], }) .await?; diff --git a/paddler_tests/tests/qwen3_internal_endpoint_pure_content_usage.rs b/paddler_tests/tests/qwen3_internal_endpoint_pure_content_usage.rs index 5c1a8f62..4332639a 100644 --- a/paddler_tests/tests/qwen3_internal_endpoint_pure_content_usage.rs +++ b/paddler_tests/tests/qwen3_internal_endpoint_pure_content_usage.rs @@ -29,6 +29,7 @@ async fn qwen3_internal_endpoint_pure_content_usage_breakdown() -> Result<()> { enable_thinking: false, grammar: None, max_tokens: 60, + parse_tool_calls: false, tools: vec![], }) .await?; diff --git a/paddler_tests/tests/qwen3_internal_endpoint_tools_without_parse_flag_emit_only_raw_tokens.rs b/paddler_tests/tests/qwen3_internal_endpoint_tools_without_parse_flag_emit_only_raw_tokens.rs new file mode 100644 index 00000000..cc097baf --- /dev/null +++ b/paddler_tests/tests/qwen3_internal_endpoint_tools_without_parse_flag_emit_only_raw_tokens.rs @@ -0,0 +1,91 @@ +#![cfg(feature = "tests_that_use_llms")] + +use anyhow::Result; +use paddler_tests::collect_generated_tokens::collect_generated_tokens; +use paddler_tests::inference_http_client::InferenceHttpClient; +use paddler_tests::start_in_process_cluster_with_qwen3::start_in_process_cluster_with_qwen3; +use paddler_types::conversation_history::ConversationHistory; +use paddler_types::conversation_message::ConversationMessage; +use paddler_types::conversation_message_content::ConversationMessageContent; +use paddler_types::generated_token_result::GeneratedTokenResult; +use paddler_types::request_params::continue_from_conversation_history_params::ContinueFromConversationHistoryParams; +use paddler_types::request_params::continue_from_conversation_history_params::tool::Tool; +use paddler_types::request_params::continue_from_conversation_history_params::tool::tool_params::FunctionCall; +use paddler_types::request_params::continue_from_conversation_history_params::tool::tool_params::function_call::function::Function; +use paddler_types::request_params::continue_from_conversation_history_params::tool::tool_params::function_call::parameters::Parameters; +use paddler_types::request_params::continue_from_conversation_history_params::tool::tool_params::function_call::parameters_schema::validated_parameters_schema::ValidatedParametersSchema; +use reqwest::Client; +use serde_json::Map; +use serde_json::Value; + +#[serial_test::file_serial(model_load, path => "../target/model_load.lock")] +#[tokio::test(flavor = "multi_thread")] +async fn qwen3_internal_endpoint_tools_without_parse_flag_emit_only_raw_tokens() -> Result<()> { + let cluster = start_in_process_cluster_with_qwen3(1).await?; + + let inference_client = + InferenceHttpClient::new(Client::new(), cluster.addresses.inference_base_url()?); + + let mut location_properties = Map::new(); + location_properties.insert( + "location".to_owned(), + serde_json::json!({"type": "string", "description": "The city name"}), + ); + + let stream = inference_client + .post_continue_from_conversation_history(&ContinueFromConversationHistoryParams { + add_generation_prompt: true, + conversation_history: ConversationHistory::new(vec![ConversationMessage { + content: ConversationMessageContent::Text( + "What is the weather in Paris? Use the get_weather tool to find out." + .to_owned(), + ), + role: "user".to_owned(), + }]), + enable_thinking: false, + grammar: None, + max_tokens: 400, + parse_tool_calls: false, + tools: vec![Tool::Function(FunctionCall { + function: Function { + name: "get_weather".to_owned(), + description: "Get the current weather for a location".to_owned(), + parameters: Parameters::Schema(ValidatedParametersSchema { + schema_type: "object".to_owned(), + properties: Some(location_properties), + required: Some(vec!["location".to_owned()]), + additional_properties: Some(Value::Bool(false)), + }), + }, + })], + }) + .await?; + + let collected = collect_generated_tokens(stream).await?; + + for event in &collected.token_results { + match event { + GeneratedTokenResult::ToolCallParsed(_) + | GeneratedTokenResult::ToolCallParseFailed(_) + | GeneratedTokenResult::ToolCallValidationFailed(_) + | GeneratedTokenResult::ToolCallValidatorBuildFailed(_) => { + anyhow::bail!( + "expected no parsed/parse-failed/validation-failed/validator-build-failed events when parse_tool_calls=false, got: {event:?}" + ); + } + _ => {} + } + } + + let last = collected + .token_results + .last() + .ok_or_else(|| anyhow::anyhow!("no token results received"))?; + let GeneratedTokenResult::Done(_) = last else { + anyhow::bail!("last result was not Done: {last:?}"); + }; + + cluster.shutdown().await?; + + Ok(()) +} diff --git a/paddler_tests/tests/qwen3_internal_endpoint_with_thinking_disabled_emits_no_reasoning_tokens.rs b/paddler_tests/tests/qwen3_internal_endpoint_with_thinking_disabled_emits_no_reasoning_tokens.rs index 9f43c164..785ff6e4 100644 --- a/paddler_tests/tests/qwen3_internal_endpoint_with_thinking_disabled_emits_no_reasoning_tokens.rs +++ b/paddler_tests/tests/qwen3_internal_endpoint_with_thinking_disabled_emits_no_reasoning_tokens.rs @@ -29,6 +29,7 @@ async fn qwen3_internal_endpoint_with_thinking_disabled_emits_no_reasoning_token enable_thinking: false, grammar: None, max_tokens: 100, + parse_tool_calls: false, tools: vec![], }) .await?; diff --git a/paddler_tests/tests/qwen3_internal_endpoint_with_thinking_enabled_emits_reasoning_tokens.rs b/paddler_tests/tests/qwen3_internal_endpoint_with_thinking_enabled_emits_reasoning_tokens.rs index dc2c6330..d9010bb5 100644 --- a/paddler_tests/tests/qwen3_internal_endpoint_with_thinking_enabled_emits_reasoning_tokens.rs +++ b/paddler_tests/tests/qwen3_internal_endpoint_with_thinking_enabled_emits_reasoning_tokens.rs @@ -31,6 +31,7 @@ async fn qwen3_internal_endpoint_with_thinking_enabled_emits_reasoning_tokens() enable_thinking: true, grammar: None, max_tokens: 600, + parse_tool_calls: false, tools: vec![], }) .await?; diff --git a/paddler_tests/tests/smolvlm2_generates_tokens_from_image_input.rs b/paddler_tests/tests/smolvlm2_generates_tokens_from_image_input.rs index de99ec94..29750426 100644 --- a/paddler_tests/tests/smolvlm2_generates_tokens_from_image_input.rs +++ b/paddler_tests/tests/smolvlm2_generates_tokens_from_image_input.rs @@ -45,6 +45,7 @@ async fn smolvlm2_generates_tokens_from_image_input() -> Result<()> { enable_thinking: false, grammar: None, max_tokens: 200, + parse_tool_calls: false, tools: vec![], }) .await?; diff --git a/paddler_types/src/generated_token_result.rs b/paddler_types/src/generated_token_result.rs index 2e82da77..26eac8d3 100644 --- a/paddler_types/src/generated_token_result.rs +++ b/paddler_types/src/generated_token_result.rs @@ -23,6 +23,7 @@ pub enum GeneratedTokenResult { ToolCallParsed(Vec), ToolCallToken(String), ToolCallValidationFailed(Vec), + ToolCallValidatorBuildFailed(String), UndeterminableToken(String), } @@ -76,6 +77,7 @@ impl StreamableResult for GeneratedTokenResult { | Self::ImageDecodingFailed(_) | Self::MultimodalNotSupported(_) | Self::SamplerError(_) + | Self::ToolCallValidatorBuildFailed(_) ) } } @@ -129,6 +131,13 @@ mod tests { assert!(GeneratedTokenResult::SamplerError("err".to_owned()).is_done()); } + #[test] + fn tool_call_validator_build_failed_is_done() { + assert!( + GeneratedTokenResult::ToolCallValidatorBuildFailed("bad schema".to_owned()).is_done() + ); + } + #[test] fn content_token_is_not_done() { assert!(!GeneratedTokenResult::ContentToken("hello".to_owned()).is_done()); diff --git a/paddler_types/src/request_params/continue_from_conversation_history_params/mod.rs b/paddler_types/src/request_params/continue_from_conversation_history_params/mod.rs index 64fbefcb..b396bff5 100644 --- a/paddler_types/src/request_params/continue_from_conversation_history_params/mod.rs +++ b/paddler_types/src/request_params/continue_from_conversation_history_params/mod.rs @@ -22,6 +22,8 @@ pub struct ContinueFromConversationHistoryParams { pub grammar: Option, pub max_tokens: i32, #[serde(default)] + pub parse_tool_calls: bool, + #[serde(default)] pub tools: Vec>, } @@ -35,6 +37,7 @@ impl Validates> enable_thinking: self.enable_thinking, grammar: self.grammar, max_tokens: self.max_tokens, + parse_tool_calls: self.parse_tool_calls, tools: self .tools .into_iter() From b000890cf69a7d9eba7f8063945bbe19071556c4 Mon Sep 17 00:00:00 2001 From: Mateusz Charytoniuk Date: Wed, 6 May 2026 01:39:27 +0200 Subject: [PATCH 14/51] introduce @intentee/paddler-client npm workspace and migrate admin panel onto it --- Makefile | 8 + jarmuz/run-website.mjs | 9 +- package-lock.json | 42 +++ package.json | 4 +- paddler_client_javascript/.gitignore | 7 + paddler_client_javascript/LICENSE | 201 +++++++++++++ paddler_client_javascript/Makefile | 10 + paddler_client_javascript/README.md | 88 ++++++ paddler_client_javascript/package.json | 46 +++ paddler_client_javascript/shell.nix | 7 + .../src/ConnectionDroppedError.ts | 9 + .../src/EventSourceConnectedState.ts | 18 ++ .../src/EventSourceConnectionErrorState.ts | 18 ++ .../src/EventSourceDataSnapshotState.ts | 10 + .../EventSourceDeserializationErrorState.ts | 18 ++ .../src/EventSourceInitialState.ts | 17 ++ .../src/EventSourceState.ts | 14 + .../src/FetchJsonEmptyState.ts | 15 + .../src/FetchJsonErrorState.ts | 7 + .../src/FetchJsonLoadingState.ts | 15 + .../src/FetchJsonState.ts | 10 + .../src/FetchJsonSuccessState.ts | 7 + paddler_client_javascript/src/HttpError.ts | 12 + paddler_client_javascript/src/JsonError.ts | 12 + paddler_client_javascript/src/PaddlerError.ts | 3 + paddler_client_javascript/src/ServerError.ts | 12 + .../src/WebSocketConnectingState.ts | 15 + .../src/WebSocketConnectionClosedState.ts | 14 + .../src/WebSocketConnectionErrorState.ts | 14 + .../src/WebSocketConnectionOpenedState.ts | 6 + .../src/WebSocketError.ts | 5 + .../src/WebSocketState.ts | 10 + .../src/extractHuggingFaceUrlParts.ts | 38 +++ paddler_client_javascript/src/fetchJson.ts | 26 ++ .../src/inferenceSocketClient.ts | 15 +- .../src}/schemas/Agent.ts | 0 .../src}/schemas/AgentDesiredModel.ts | 0 .../src}/schemas/AgentIssue.ts | 0 .../src}/schemas/AgentIssueModelPath.ts | 0 .../src}/schemas/AgentsResponse.ts | 0 .../src}/schemas/BalancerDesiredState.ts | 0 .../src}/schemas/BufferedRequestsResponse.ts | 0 .../src}/schemas/ChatTemplate.ts | 0 .../ContinueFromConversationHistoryParams.ts | 21 ++ .../schemas/ContinueFromRawPromptParams.ts | 15 + .../src/schemas/ConversationMessage.ts | 13 + .../schemas/ConversationMessageContentPart.ts | 16 + .../src/schemas/Embedding.ts | 13 + .../src/schemas/EmbeddingInputDocument.ts | 10 + .../schemas/EmbeddingNormalizationMethod.ts | 15 + .../schemas/GenerateEmbeddingBatchParams.ts | 13 + .../src/schemas/GrammarConstraint.ts | 15 + .../src}/schemas/HuggingFaceDownloadLock.ts | 1 + .../src}/schemas/HuggingFaceModelReference.ts | 0 .../src}/schemas/InferenceParameters.ts | 11 - .../InferenceServiceGenerateTokensResponse.ts | 284 ++++++++++++++++++ .../src/schemas/ModelMetadata.ts | 5 + .../src/schemas/ParsedToolCall.ts | 15 + .../src/schemas/PoolingType.ts | 12 + paddler_client_javascript/src/schemas/Tool.ts | 20 ++ .../src/schemas/ValidatedParametersSchema.ts | 14 + .../src/streamEventSource.ts | 69 +++++ .../src/streamHttpNdjson.ts | 104 +++++++ .../src}/urlToAgentDesiredModel.ts | 10 +- .../src/webSocketProtocol.ts | 3 + .../tests/PaddlerError.test.ts | 49 +++ .../tests/extractHuggingFaceUrlParts.test.ts | 47 +++ .../tests/fetchJson.test.ts | 71 +++++ .../tests/schemas/Agent.test.ts | 42 +++ ...renceServiceGenerateTokensResponse.test.ts | 86 ++++++ .../tests/schemas/ParsedToolCall.test.ts | 35 +++ .../tests/streamHttpNdjson.test.ts | 85 ++++++ .../tests/urlToAgentDesiredModel.test.ts | 36 +++ .../tests/webSocketProtocol.test.ts | 15 + paddler_client_javascript/tsconfig.json | 20 ++ resources/ts/ConversationMessage.type.ts | 6 - .../ts/ConversationMessageContentPart.type.ts | 3 - .../ts/InferenceSocketClient.interface.ts | 11 - resources/ts/components/AgentIssues.tsx | 2 +- .../components/AgentIssuesPreviewButton.tsx | 2 +- resources/ts/components/AgentList.tsx | 2 +- .../ts/components/AgentListAgentStatus.tsx | 2 +- resources/ts/components/AgentListStream.tsx | 2 +- .../ts/components/BufferedRequestsStream.tsx | 2 +- resources/ts/components/ChangeModelForm.tsx | 2 +- resources/ts/components/ChangeModelPage.tsx | 2 +- .../ChatTemplateContextProvider.tsx | 2 +- .../ts/components/ChatTemplateEditButton.tsx | 2 +- .../components/ChatTemplateOverrideLoader.tsx | 2 +- ...nversationMessagePromptGeneratedTokens.tsx | 48 ++- .../InferenceParameterCacheDtype.tsx | 2 +- .../components/InferenceParameterCheckbox.tsx | 4 +- .../ts/components/InferenceParameterInput.tsx | 8 +- .../InferenceParameterPoolingType.tsx | 2 +- .../InferenceParametersContextProvider.tsx | 2 +- ...ModelChatTemplateOverridePreviewButton.tsx | 2 +- resources/ts/components/ModelMetadata.tsx | 2 +- .../ts/components/ModelMetadataLoader.tsx | 2 +- .../components/ModelMetadataPreviewButton.tsx | 2 +- resources/ts/components/PromptPage.tsx | 2 +- resources/ts/contexts/ChatTemplateContext.ts | 2 +- .../ts/contexts/InferenceParametersContext.ts | 2 +- resources/ts/extractHuggingFaceUrlParts.ts | 46 --- resources/ts/hooks/useAgentDesiredModelUrl.ts | 4 +- resources/ts/hooks/useBalancerDesiredState.ts | 2 +- resources/ts/hooks/useChatTemplateOverride.ts | 2 +- resources/ts/hooks/useEventSourceUpdates.ts | 145 +-------- resources/ts/hooks/useFetchJson.ts | 68 +---- resources/ts/hooks/usePrompt.ts | 75 ++--- resources/ts/hooks/useWebSocket.ts | 66 +--- resources/ts/inferenceParametersFormKeys.ts | 17 ++ resources/ts/matchEventSourceUpdateState.ts | 27 +- resources/ts/matchFetchJsonState.ts | 28 +- resources/ts/matchWebSocketState.ts | 23 +- .../InferenceServiceGenerateTokensResponse.ts | 144 --------- resources/ts/urlToAgentDesiredModel_test.ts | 24 -- resources/ts/webSocketProtocol.ts | 3 - tsconfig.json | 2 +- 118 files changed, 2062 insertions(+), 663 deletions(-) create mode 100644 paddler_client_javascript/.gitignore create mode 100644 paddler_client_javascript/LICENSE create mode 100644 paddler_client_javascript/Makefile create mode 100644 paddler_client_javascript/README.md create mode 100644 paddler_client_javascript/package.json create mode 100644 paddler_client_javascript/shell.nix create mode 100644 paddler_client_javascript/src/ConnectionDroppedError.ts create mode 100644 paddler_client_javascript/src/EventSourceConnectedState.ts create mode 100644 paddler_client_javascript/src/EventSourceConnectionErrorState.ts create mode 100644 paddler_client_javascript/src/EventSourceDataSnapshotState.ts create mode 100644 paddler_client_javascript/src/EventSourceDeserializationErrorState.ts create mode 100644 paddler_client_javascript/src/EventSourceInitialState.ts create mode 100644 paddler_client_javascript/src/EventSourceState.ts create mode 100644 paddler_client_javascript/src/FetchJsonEmptyState.ts create mode 100644 paddler_client_javascript/src/FetchJsonErrorState.ts create mode 100644 paddler_client_javascript/src/FetchJsonLoadingState.ts create mode 100644 paddler_client_javascript/src/FetchJsonState.ts create mode 100644 paddler_client_javascript/src/FetchJsonSuccessState.ts create mode 100644 paddler_client_javascript/src/HttpError.ts create mode 100644 paddler_client_javascript/src/JsonError.ts create mode 100644 paddler_client_javascript/src/PaddlerError.ts create mode 100644 paddler_client_javascript/src/ServerError.ts create mode 100644 paddler_client_javascript/src/WebSocketConnectingState.ts create mode 100644 paddler_client_javascript/src/WebSocketConnectionClosedState.ts create mode 100644 paddler_client_javascript/src/WebSocketConnectionErrorState.ts create mode 100644 paddler_client_javascript/src/WebSocketConnectionOpenedState.ts create mode 100644 paddler_client_javascript/src/WebSocketError.ts create mode 100644 paddler_client_javascript/src/WebSocketState.ts create mode 100644 paddler_client_javascript/src/extractHuggingFaceUrlParts.ts create mode 100644 paddler_client_javascript/src/fetchJson.ts rename resources/ts/InferenceSocketClient.ts => paddler_client_javascript/src/inferenceSocketClient.ts (84%) rename {resources/ts => paddler_client_javascript/src}/schemas/Agent.ts (100%) rename {resources/ts => paddler_client_javascript/src}/schemas/AgentDesiredModel.ts (100%) rename {resources/ts => paddler_client_javascript/src}/schemas/AgentIssue.ts (100%) rename {resources/ts => paddler_client_javascript/src}/schemas/AgentIssueModelPath.ts (100%) rename {resources/ts => paddler_client_javascript/src}/schemas/AgentsResponse.ts (100%) rename {resources/ts => paddler_client_javascript/src}/schemas/BalancerDesiredState.ts (100%) rename {resources/ts => paddler_client_javascript/src}/schemas/BufferedRequestsResponse.ts (100%) rename {resources/ts => paddler_client_javascript/src}/schemas/ChatTemplate.ts (100%) create mode 100644 paddler_client_javascript/src/schemas/ContinueFromConversationHistoryParams.ts create mode 100644 paddler_client_javascript/src/schemas/ContinueFromRawPromptParams.ts create mode 100644 paddler_client_javascript/src/schemas/ConversationMessage.ts create mode 100644 paddler_client_javascript/src/schemas/ConversationMessageContentPart.ts create mode 100644 paddler_client_javascript/src/schemas/Embedding.ts create mode 100644 paddler_client_javascript/src/schemas/EmbeddingInputDocument.ts create mode 100644 paddler_client_javascript/src/schemas/EmbeddingNormalizationMethod.ts create mode 100644 paddler_client_javascript/src/schemas/GenerateEmbeddingBatchParams.ts create mode 100644 paddler_client_javascript/src/schemas/GrammarConstraint.ts rename {resources/ts => paddler_client_javascript/src}/schemas/HuggingFaceDownloadLock.ts (99%) rename {resources/ts => paddler_client_javascript/src}/schemas/HuggingFaceModelReference.ts (100%) rename {resources/ts => paddler_client_javascript/src}/schemas/InferenceParameters.ts (75%) create mode 100644 paddler_client_javascript/src/schemas/InferenceServiceGenerateTokensResponse.ts create mode 100644 paddler_client_javascript/src/schemas/ModelMetadata.ts create mode 100644 paddler_client_javascript/src/schemas/ParsedToolCall.ts create mode 100644 paddler_client_javascript/src/schemas/PoolingType.ts create mode 100644 paddler_client_javascript/src/schemas/Tool.ts create mode 100644 paddler_client_javascript/src/schemas/ValidatedParametersSchema.ts create mode 100644 paddler_client_javascript/src/streamEventSource.ts create mode 100644 paddler_client_javascript/src/streamHttpNdjson.ts rename {resources/ts => paddler_client_javascript/src}/urlToAgentDesiredModel.ts (66%) create mode 100644 paddler_client_javascript/src/webSocketProtocol.ts create mode 100644 paddler_client_javascript/tests/PaddlerError.test.ts create mode 100644 paddler_client_javascript/tests/extractHuggingFaceUrlParts.test.ts create mode 100644 paddler_client_javascript/tests/fetchJson.test.ts create mode 100644 paddler_client_javascript/tests/schemas/Agent.test.ts create mode 100644 paddler_client_javascript/tests/schemas/InferenceServiceGenerateTokensResponse.test.ts create mode 100644 paddler_client_javascript/tests/schemas/ParsedToolCall.test.ts create mode 100644 paddler_client_javascript/tests/streamHttpNdjson.test.ts create mode 100644 paddler_client_javascript/tests/urlToAgentDesiredModel.test.ts create mode 100644 paddler_client_javascript/tests/webSocketProtocol.test.ts create mode 100644 paddler_client_javascript/tsconfig.json delete mode 100644 resources/ts/ConversationMessage.type.ts delete mode 100644 resources/ts/ConversationMessageContentPart.type.ts delete mode 100644 resources/ts/InferenceSocketClient.interface.ts delete mode 100644 resources/ts/extractHuggingFaceUrlParts.ts create mode 100644 resources/ts/inferenceParametersFormKeys.ts delete mode 100644 resources/ts/schemas/InferenceServiceGenerateTokensResponse.ts delete mode 100644 resources/ts/urlToAgentDesiredModel_test.ts delete mode 100644 resources/ts/webSocketProtocol.ts diff --git a/Makefile b/Makefile index 34bea6c8..2308cd7d 100644 --- a/Makefile +++ b/Makefile @@ -82,6 +82,14 @@ test.integration.metal: target/metal/debug/paddler test.unit: esbuild-meta.json cargo test --features web_admin_panel +.PHONY: build.client.js +build.client.js: + npm --workspace @intentee/paddler-client run build + +.PHONY: test.client.js +test.client.js: + npm --workspace @intentee/paddler-client test + .PHONY: watch watch: node_modules ./jarmuz-watch.mjs diff --git a/jarmuz/run-website.mjs b/jarmuz/run-website.mjs index 0b8028a2..ee8453e5 100644 --- a/jarmuz/run-website.mjs +++ b/jarmuz/run-website.mjs @@ -6,7 +6,13 @@ export function run({ development, once = false, rustJobs }) { jarmuz({ once, pipeline: ["stylelint", "tcm", "tsc", "eslint", esbuildJob, ...rustJobs], - watch: ["paddler", "paddler_client", "paddler_types", "resources"], + watch: [ + "paddler", + "paddler_client", + "paddler_client_javascript", + "paddler_types", + "resources", + ], }).decide(function ({ matches, schedule }) { if (matches("resources/**/*.css")) { schedule("stylelint"); @@ -14,6 +20,7 @@ export function run({ development, once = false, rustJobs }) { switch (true) { case matches("resources/**/*.{ts,tsx}"): + case matches("paddler_client_javascript/src/**/*.ts"): schedule("tsc"); schedule("eslint"); break; diff --git a/package-lock.json b/package-lock.json index 88c5d46e..6b6359e4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -6,6 +6,9 @@ "": { "name": "paddler", "license": "Apache-2.0", + "workspaces": [ + "paddler_client_javascript" + ], "dependencies": { "@codemirror/lang-jinja": "^6.0.0", "@uiw/react-codemirror": "^4.24.2", @@ -979,6 +982,10 @@ "url": "https://github.com/sponsors/nzakas" } }, + "node_modules/@intentee/paddler-client": { + "resolved": "paddler_client_javascript", + "link": true + }, "node_modules/@isaacs/cached": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/@isaacs/cached/-/cached-1.0.1.tgz", @@ -1291,6 +1298,16 @@ "integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==", "license": "MIT" }, + "node_modules/@types/node": { + "version": "22.19.17", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.17.tgz", + "integrity": "sha512-wGdMcf+vPYM6jikpS/qhg6WiqSV/OhG+jeeHT/KlVqxYfD40iYJf9/AE1uQxVWFvU7MipKRkRv8NSHiCGgPr8Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, "node_modules/@types/react": { "version": "19.1.10", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.1.10.tgz", @@ -7213,6 +7230,13 @@ "typescript": ">=4.8.4 <6.0.0" } }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + }, "node_modules/unicorn-magic": { "version": "0.3.0", "resolved": "https://registry.npmjs.org/unicorn-magic/-/unicorn-magic-0.3.0.tgz", @@ -7773,6 +7797,24 @@ "type": "github", "url": "https://github.com/sponsors/wooorm" } + }, + "paddler_client_javascript": { + "name": "@intentee/paddler-client", + "version": "3.1.2", + "license": "Apache-2.0", + "devDependencies": { + "@types/node": "^22", + "ava": "^6.4.1", + "tsimp": "^2.0.12", + "typescript": "^5.9.2" + }, + "engines": { + "node": ">=22" + }, + "peerDependencies": { + "rxjs": "^7.8", + "zod": "^4" + } } } } diff --git a/package.json b/package.json index d2c84c10..37905f24 100644 --- a/package.json +++ b/package.json @@ -46,10 +46,12 @@ }, "license": "Apache-2.0", "name": "paddler", + "private": true, "overrides": { "node-notifier": { "uuid": "^14.0.0" } }, - "type": "module" + "type": "module", + "workspaces": ["paddler_client_javascript"] } diff --git a/paddler_client_javascript/.gitignore b/paddler_client_javascript/.gitignore new file mode 100644 index 00000000..7469f1d3 --- /dev/null +++ b/paddler_client_javascript/.gitignore @@ -0,0 +1,7 @@ +node_modules/ +dist/ +.tsimp/ +*.tsbuildinfo +*.log +coverage/ +.nyc_output/ diff --git a/paddler_client_javascript/LICENSE b/paddler_client_javascript/LICENSE new file mode 100644 index 00000000..261eeb9e --- /dev/null +++ b/paddler_client_javascript/LICENSE @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/paddler_client_javascript/Makefile b/paddler_client_javascript/Makefile new file mode 100644 index 00000000..97147eae --- /dev/null +++ b/paddler_client_javascript/Makefile @@ -0,0 +1,10 @@ +.PHONY: build +build: + npm run build + +.PHONY: test +test: + npm test + +.PHONY: check +check: test diff --git a/paddler_client_javascript/README.md b/paddler_client_javascript/README.md new file mode 100644 index 00000000..1f1e23d0 --- /dev/null +++ b/paddler_client_javascript/README.md @@ -0,0 +1,88 @@ +# @intentee/paddler-client + +JavaScript/TypeScript client for the [Paddler](https://github.com/intentee/paddler) LLM load balancer. + +## Install + +```sh +npm install @intentee/paddler-client rxjs zod +``` + +`rxjs` and `zod` are peer dependencies. + +## Quick start + +### WebSocket inference (multiplexed, request-id-keyed) + +```ts +import { inferenceSocketClient } from "@intentee/paddler-client"; + +const webSocket = new WebSocket("ws://localhost:8061/api/v1/inference_socket"); + +const { continueConversation } = inferenceSocketClient({ webSocket }); + +continueConversation({ + enableThinking: true, + messages: [ + { role: "system", content: "You are a helpful assistant." }, + { role: "user", content: "Hello" }, + ], +}).subscribe((chunk) => { + if (chunk.error) { + console.error(chunk.error); + return; + } + if (chunk.done) { + console.log("done", chunk.summary); + return; + } + if (chunk.token !== null) { + process.stdout.write(chunk.token); + } +}); +``` + +### HTTP NDJSON streaming + +```ts +import { streamHttpNdjson, InferenceServiceGenerateTokensResponseSchema } from "@intentee/paddler-client"; + +const controller = new AbortController(); + +streamHttpNdjson({ + url: new URL("http://localhost:8061/api/v1/continue_from_conversation_history"), + body: { add_generation_prompt: true, conversation_history: [...], max_tokens: 200 }, + signal: controller.signal, + schema: InferenceServiceGenerateTokensResponseSchema, +}).subscribe(/* ... */); +``` + +### SSE management stream + +```ts +import { streamEventSource, AgentsResponseSchema, matchEventSourceUpdateState } from "@intentee/paddler-client"; + +streamEventSource({ + url: new URL("http://localhost:8062/api/v1/agents/stream"), + schema: AgentsResponseSchema, +}).subscribe((state) => { + matchEventSourceUpdateState(state, { + initial: () => console.log("connecting"), + connected: () => console.log("connected"), + dataSnapshot: ({ data }) => console.log("agents", data.agents.length), + connectionError: () => console.error("connection lost"), + deserializationError: () => console.error("invalid payload"), + }); +}); +``` + +## Coverage + +- Transport: WebSocket (multiplexed), HTTP NDJSON, HTTP JSON, Server-Sent Events +- Schemas: every Paddler wire-format type (validated via zod) +- State machines + exhaustive matchers for connection/stream/fetch states +- Specialized error types per failure mode + +## License + +Apache-2.0 diff --git a/paddler_client_javascript/package.json b/paddler_client_javascript/package.json new file mode 100644 index 00000000..62d84110 --- /dev/null +++ b/paddler_client_javascript/package.json @@ -0,0 +1,46 @@ +{ + "name": "@intentee/paddler-client", + "version": "3.1.2", + "description": "JavaScript/TypeScript client for the Paddler LLM load balancer", + "license": "Apache-2.0", + "homepage": "https://github.com/intentee/paddler", + "repository": "https://github.com/intentee/paddler", + "type": "module", + "exports": { + "./*": "./src/*.ts" + }, + "publishConfig": { + "exports": { + "./*": { + "types": "./dist/*.d.ts", + "default": "./dist/*.js" + } + } + }, + "files": ["dist", "README.md", "LICENSE"], + "scripts": { + "build": "tsc -p .", + "test": "ava", + "prepack": "tsc -p ." + }, + "peerDependencies": { + "rxjs": "^7.8", + "zod": "^4" + }, + "devDependencies": { + "@types/node": "^22", + "ava": "^6.4.1", + "tsimp": "^2.0.12", + "typescript": "^5.9.2" + }, + "engines": { + "node": ">=22" + }, + "ava": { + "extensions": { + "ts": "module" + }, + "nodeArguments": ["--import=tsimp"], + "files": ["tests/**/*.test.ts"] + } +} diff --git a/paddler_client_javascript/shell.nix b/paddler_client_javascript/shell.nix new file mode 100644 index 00000000..1ebe6e4a --- /dev/null +++ b/paddler_client_javascript/shell.nix @@ -0,0 +1,7 @@ +{ pkgs ? import {} }: + +pkgs.mkShell { + nativeBuildInputs = with pkgs; [ + nodejs_22 + ]; +} diff --git a/paddler_client_javascript/src/ConnectionDroppedError.ts b/paddler_client_javascript/src/ConnectionDroppedError.ts new file mode 100644 index 00000000..b8e315f8 --- /dev/null +++ b/paddler_client_javascript/src/ConnectionDroppedError.ts @@ -0,0 +1,9 @@ +import { PaddlerError } from "./PaddlerError"; + +export class ConnectionDroppedError extends PaddlerError { + override name = "ConnectionDroppedError"; + + constructor(public readonly requestId: string) { + super(`Connection dropped while streaming request ${requestId}`); + } +} diff --git a/paddler_client_javascript/src/EventSourceConnectedState.ts b/paddler_client_javascript/src/EventSourceConnectedState.ts new file mode 100644 index 00000000..25b531d2 --- /dev/null +++ b/paddler_client_javascript/src/EventSourceConnectedState.ts @@ -0,0 +1,18 @@ +export type EventSourceConnectedState = { + data: undefined; + isConnected: true; + isConnectionError: false; + isDeserializationError: false; + isInitial: false; + isOk: false; +}; + +export const eventSourceConnectedState: EventSourceConnectedState = + Object.freeze({ + data: undefined, + isConnected: true, + isConnectionError: false, + isDeserializationError: false, + isInitial: false, + isOk: false, + }); diff --git a/paddler_client_javascript/src/EventSourceConnectionErrorState.ts b/paddler_client_javascript/src/EventSourceConnectionErrorState.ts new file mode 100644 index 00000000..6891fb63 --- /dev/null +++ b/paddler_client_javascript/src/EventSourceConnectionErrorState.ts @@ -0,0 +1,18 @@ +export type EventSourceConnectionErrorState = { + data: undefined; + isConnected: false; + isConnectionError: true; + isDeserializationError: false; + isInitial: false; + isOk: false; +}; + +export const eventSourceConnectionErrorState: EventSourceConnectionErrorState = + Object.freeze({ + data: undefined, + isConnected: false, + isConnectionError: true, + isDeserializationError: false, + isInitial: false, + isOk: false, + }); diff --git a/paddler_client_javascript/src/EventSourceDataSnapshotState.ts b/paddler_client_javascript/src/EventSourceDataSnapshotState.ts new file mode 100644 index 00000000..a97331b5 --- /dev/null +++ b/paddler_client_javascript/src/EventSourceDataSnapshotState.ts @@ -0,0 +1,10 @@ +import type { z } from "zod"; + +export type EventSourceDataSnapshotState = { + data: z.infer; + isConnected: true; + isConnectionError: false; + isDeserializationError: false; + isInitial: false; + isOk: true; +}; diff --git a/paddler_client_javascript/src/EventSourceDeserializationErrorState.ts b/paddler_client_javascript/src/EventSourceDeserializationErrorState.ts new file mode 100644 index 00000000..c24fe02d --- /dev/null +++ b/paddler_client_javascript/src/EventSourceDeserializationErrorState.ts @@ -0,0 +1,18 @@ +export type EventSourceDeserializationErrorState = { + data: undefined; + isConnected: true; + isConnectionError: false; + isDeserializationError: true; + isInitial: false; + isOk: false; +}; + +export const eventSourceDeserializationErrorState: EventSourceDeserializationErrorState = + Object.freeze({ + data: undefined, + isConnected: true, + isConnectionError: false, + isDeserializationError: true, + isInitial: false, + isOk: false, + }); diff --git a/paddler_client_javascript/src/EventSourceInitialState.ts b/paddler_client_javascript/src/EventSourceInitialState.ts new file mode 100644 index 00000000..f00fad6b --- /dev/null +++ b/paddler_client_javascript/src/EventSourceInitialState.ts @@ -0,0 +1,17 @@ +export type EventSourceInitialState = { + data: undefined; + isConnected: false; + isConnectionError: false; + isDeserializationError: false; + isInitial: true; + isOk: false; +}; + +export const eventSourceInitialState: EventSourceInitialState = Object.freeze({ + data: undefined, + isConnected: false, + isConnectionError: false, + isDeserializationError: false, + isInitial: true, + isOk: false, +}); diff --git a/paddler_client_javascript/src/EventSourceState.ts b/paddler_client_javascript/src/EventSourceState.ts new file mode 100644 index 00000000..106d11cc --- /dev/null +++ b/paddler_client_javascript/src/EventSourceState.ts @@ -0,0 +1,14 @@ +import type { z } from "zod"; + +import type { EventSourceConnectedState } from "./EventSourceConnectedState"; +import type { EventSourceConnectionErrorState } from "./EventSourceConnectionErrorState"; +import type { EventSourceDataSnapshotState } from "./EventSourceDataSnapshotState"; +import type { EventSourceDeserializationErrorState } from "./EventSourceDeserializationErrorState"; +import type { EventSourceInitialState } from "./EventSourceInitialState"; + +export type EventSourceState = + | EventSourceConnectedState + | EventSourceConnectionErrorState + | EventSourceDataSnapshotState + | EventSourceDeserializationErrorState + | EventSourceInitialState; diff --git a/paddler_client_javascript/src/FetchJsonEmptyState.ts b/paddler_client_javascript/src/FetchJsonEmptyState.ts new file mode 100644 index 00000000..ffa04ef3 --- /dev/null +++ b/paddler_client_javascript/src/FetchJsonEmptyState.ts @@ -0,0 +1,15 @@ +export type FetchJsonEmptyState = { + empty: true; + error: null; + loading: false; + ok: false; + response: null; +}; + +export const fetchJsonEmptyState: FetchJsonEmptyState = Object.freeze({ + empty: true, + error: null, + loading: false, + ok: false, + response: null, +}); diff --git a/paddler_client_javascript/src/FetchJsonErrorState.ts b/paddler_client_javascript/src/FetchJsonErrorState.ts new file mode 100644 index 00000000..a5a9aff5 --- /dev/null +++ b/paddler_client_javascript/src/FetchJsonErrorState.ts @@ -0,0 +1,7 @@ +export type FetchJsonErrorState = { + empty: false; + error: string; + loading: false; + ok: false; + response: null; +}; diff --git a/paddler_client_javascript/src/FetchJsonLoadingState.ts b/paddler_client_javascript/src/FetchJsonLoadingState.ts new file mode 100644 index 00000000..71b5bc4b --- /dev/null +++ b/paddler_client_javascript/src/FetchJsonLoadingState.ts @@ -0,0 +1,15 @@ +export type FetchJsonLoadingState = { + empty: false; + error: null; + loading: true; + ok: false; + response: null; +}; + +export const fetchJsonLoadingState: FetchJsonLoadingState = Object.freeze({ + empty: false, + error: null, + loading: true, + ok: false, + response: null, +}); diff --git a/paddler_client_javascript/src/FetchJsonState.ts b/paddler_client_javascript/src/FetchJsonState.ts new file mode 100644 index 00000000..4112640b --- /dev/null +++ b/paddler_client_javascript/src/FetchJsonState.ts @@ -0,0 +1,10 @@ +import type { FetchJsonEmptyState } from "./FetchJsonEmptyState"; +import type { FetchJsonErrorState } from "./FetchJsonErrorState"; +import type { FetchJsonLoadingState } from "./FetchJsonLoadingState"; +import type { FetchJsonSuccessState } from "./FetchJsonSuccessState"; + +export type FetchJsonState = + | FetchJsonEmptyState + | FetchJsonErrorState + | FetchJsonLoadingState + | FetchJsonSuccessState; diff --git a/paddler_client_javascript/src/FetchJsonSuccessState.ts b/paddler_client_javascript/src/FetchJsonSuccessState.ts new file mode 100644 index 00000000..f5ec89e9 --- /dev/null +++ b/paddler_client_javascript/src/FetchJsonSuccessState.ts @@ -0,0 +1,7 @@ +export type FetchJsonSuccessState = { + empty: false; + error: null; + loading: false; + ok: true; + response: TResult; +}; diff --git a/paddler_client_javascript/src/HttpError.ts b/paddler_client_javascript/src/HttpError.ts new file mode 100644 index 00000000..285d5b06 --- /dev/null +++ b/paddler_client_javascript/src/HttpError.ts @@ -0,0 +1,12 @@ +import { PaddlerError } from "./PaddlerError"; + +export class HttpError extends PaddlerError { + override name = "HttpError"; + + constructor( + public readonly statusCode: number, + message: string, + ) { + super(message); + } +} diff --git a/paddler_client_javascript/src/JsonError.ts b/paddler_client_javascript/src/JsonError.ts new file mode 100644 index 00000000..b6c03ccc --- /dev/null +++ b/paddler_client_javascript/src/JsonError.ts @@ -0,0 +1,12 @@ +import { PaddlerError } from "./PaddlerError"; + +export class JsonError extends PaddlerError { + override name = "JsonError"; + + constructor( + message: string, + public readonly raw: string, + ) { + super(message); + } +} diff --git a/paddler_client_javascript/src/PaddlerError.ts b/paddler_client_javascript/src/PaddlerError.ts new file mode 100644 index 00000000..b470ce4c --- /dev/null +++ b/paddler_client_javascript/src/PaddlerError.ts @@ -0,0 +1,3 @@ +export class PaddlerError extends Error { + override name = "PaddlerError"; +} diff --git a/paddler_client_javascript/src/ServerError.ts b/paddler_client_javascript/src/ServerError.ts new file mode 100644 index 00000000..38d04232 --- /dev/null +++ b/paddler_client_javascript/src/ServerError.ts @@ -0,0 +1,12 @@ +import { PaddlerError } from "./PaddlerError"; + +export class ServerError extends PaddlerError { + override name = "ServerError"; + + constructor( + public readonly code: number, + message: string, + ) { + super(message); + } +} diff --git a/paddler_client_javascript/src/WebSocketConnectingState.ts b/paddler_client_javascript/src/WebSocketConnectingState.ts new file mode 100644 index 00000000..f7271516 --- /dev/null +++ b/paddler_client_javascript/src/WebSocketConnectingState.ts @@ -0,0 +1,15 @@ +export type WebSocketConnectingState = { + isConnected: false; + isConnectionClosed: false; + isConnectionError: false; + webSocket: null; +}; + +export const webSocketConnectingState: WebSocketConnectingState = Object.freeze( + { + isConnected: false, + isConnectionClosed: false, + isConnectionError: false, + webSocket: null, + }, +); diff --git a/paddler_client_javascript/src/WebSocketConnectionClosedState.ts b/paddler_client_javascript/src/WebSocketConnectionClosedState.ts new file mode 100644 index 00000000..99ddf763 --- /dev/null +++ b/paddler_client_javascript/src/WebSocketConnectionClosedState.ts @@ -0,0 +1,14 @@ +export type WebSocketConnectionClosedState = { + isConnected: false; + isConnectionClosed: true; + isConnectionError: false; + webSocket: null; +}; + +export const webSocketConnectionClosedState: WebSocketConnectionClosedState = + Object.freeze({ + isConnected: false, + isConnectionClosed: true, + isConnectionError: false, + webSocket: null, + }); diff --git a/paddler_client_javascript/src/WebSocketConnectionErrorState.ts b/paddler_client_javascript/src/WebSocketConnectionErrorState.ts new file mode 100644 index 00000000..1e01bda4 --- /dev/null +++ b/paddler_client_javascript/src/WebSocketConnectionErrorState.ts @@ -0,0 +1,14 @@ +export type WebSocketConnectionErrorState = { + isConnected: false; + isConnectionClosed: false; + isConnectionError: true; + webSocket: null; +}; + +export const webSocketConnectionErrorState: WebSocketConnectionErrorState = + Object.freeze({ + isConnected: false, + isConnectionClosed: false, + isConnectionError: true, + webSocket: null, + }); diff --git a/paddler_client_javascript/src/WebSocketConnectionOpenedState.ts b/paddler_client_javascript/src/WebSocketConnectionOpenedState.ts new file mode 100644 index 00000000..435e532f --- /dev/null +++ b/paddler_client_javascript/src/WebSocketConnectionOpenedState.ts @@ -0,0 +1,6 @@ +export type WebSocketConnectionOpenedState = { + isConnected: true; + isConnectionClosed: false; + isConnectionError: false; + webSocket: WebSocket; +}; diff --git a/paddler_client_javascript/src/WebSocketError.ts b/paddler_client_javascript/src/WebSocketError.ts new file mode 100644 index 00000000..7274ed4d --- /dev/null +++ b/paddler_client_javascript/src/WebSocketError.ts @@ -0,0 +1,5 @@ +import { PaddlerError } from "./PaddlerError"; + +export class WebSocketError extends PaddlerError { + override name = "WebSocketError"; +} diff --git a/paddler_client_javascript/src/WebSocketState.ts b/paddler_client_javascript/src/WebSocketState.ts new file mode 100644 index 00000000..ca2968ae --- /dev/null +++ b/paddler_client_javascript/src/WebSocketState.ts @@ -0,0 +1,10 @@ +import type { WebSocketConnectingState } from "./WebSocketConnectingState"; +import type { WebSocketConnectionClosedState } from "./WebSocketConnectionClosedState"; +import type { WebSocketConnectionErrorState } from "./WebSocketConnectionErrorState"; +import type { WebSocketConnectionOpenedState } from "./WebSocketConnectionOpenedState"; + +export type WebSocketState = + | WebSocketConnectingState + | WebSocketConnectionClosedState + | WebSocketConnectionErrorState + | WebSocketConnectionOpenedState; diff --git a/paddler_client_javascript/src/extractHuggingFaceUrlParts.ts b/paddler_client_javascript/src/extractHuggingFaceUrlParts.ts new file mode 100644 index 00000000..ec7e6f5d --- /dev/null +++ b/paddler_client_javascript/src/extractHuggingFaceUrlParts.ts @@ -0,0 +1,38 @@ +import type { HuggingFaceModelReference } from "./schemas/HuggingFaceModelReference"; + +export function extractHuggingFaceUrlParts({ + pathname, +}: URL): HuggingFaceModelReference { + const segments = pathname.split("/").filter(function (segment) { + return segment.length > 0; + }); + + if (segments.length < 5) { + throw new Error(`Invalid Hugging Face URL format: ${pathname}`); + } + + const [owner, repo, resourceKind, revision, ...filenameSegments] = segments; + + if ( + owner === undefined + || repo === undefined + || resourceKind === undefined + || revision === undefined + ) { + throw new Error(`Invalid Hugging Face URL format: ${pathname}`); + } + + if (resourceKind !== "blob" && resourceKind !== "resolve") { + throw new Error(`Invalid Hugging Face URL format: ${pathname}`); + } + + if (filenameSegments.length < 1) { + throw new Error(`Invalid Hugging Face URL format: ${pathname}`); + } + + return { + filename: filenameSegments.join("/"), + repo_id: `${owner}/${repo}`, + revision, + }; +} diff --git a/paddler_client_javascript/src/fetchJson.ts b/paddler_client_javascript/src/fetchJson.ts new file mode 100644 index 00000000..3f263b3e --- /dev/null +++ b/paddler_client_javascript/src/fetchJson.ts @@ -0,0 +1,26 @@ +import type { z } from "zod"; + +import { HttpError } from "./HttpError"; + +export async function fetchJson({ + url, + signal, + schema, +}: { + url: URL | string; + signal: AbortSignal; + schema: TSchema; +}): Promise> { + const response = await fetch(url, { signal }); + + if (!response.ok) { + throw new HttpError( + response.status, + `HTTP ${response.status} ${response.statusText}`, + ); + } + + const payload: unknown = await response.json(); + + return schema.parse(payload); +} diff --git a/resources/ts/InferenceSocketClient.ts b/paddler_client_javascript/src/inferenceSocketClient.ts similarity index 84% rename from resources/ts/InferenceSocketClient.ts rename to paddler_client_javascript/src/inferenceSocketClient.ts index 8b3bf261..a9700f97 100644 --- a/resources/ts/InferenceSocketClient.ts +++ b/paddler_client_javascript/src/inferenceSocketClient.ts @@ -1,14 +1,19 @@ -import { nanoid } from "nanoid"; import { filter, fromEvent, map, takeWhile, type Observable } from "rxjs"; -import { type ConversationMessage } from "./ConversationMessage.type"; -import { type InferenceSocketClient } from "./InferenceSocketClient.interface"; import { InferenceServiceGenerateTokensResponseSchema, type InferenceServiceGenerateTokensResponse, } from "./schemas/InferenceServiceGenerateTokensResponse"; +import type { ConversationMessage } from "./schemas/ConversationMessage"; -export function InferenceSocketClient({ +export interface InferenceSocketClient { + continueConversation(params: { + enableThinking: boolean; + messages: ConversationMessage[]; + }): Observable; +} + +export function inferenceSocketClient({ webSocket, }: { webSocket: WebSocket; @@ -20,7 +25,7 @@ export function InferenceSocketClient({ enableThinking: boolean; messages: ConversationMessage[]; }): Observable { - const requestId = nanoid(); + const requestId = crypto.randomUUID(); const tokenStream = fromEvent(webSocket, "message").pipe( map(function (event): unknown { return event.data; diff --git a/resources/ts/schemas/Agent.ts b/paddler_client_javascript/src/schemas/Agent.ts similarity index 100% rename from resources/ts/schemas/Agent.ts rename to paddler_client_javascript/src/schemas/Agent.ts diff --git a/resources/ts/schemas/AgentDesiredModel.ts b/paddler_client_javascript/src/schemas/AgentDesiredModel.ts similarity index 100% rename from resources/ts/schemas/AgentDesiredModel.ts rename to paddler_client_javascript/src/schemas/AgentDesiredModel.ts diff --git a/resources/ts/schemas/AgentIssue.ts b/paddler_client_javascript/src/schemas/AgentIssue.ts similarity index 100% rename from resources/ts/schemas/AgentIssue.ts rename to paddler_client_javascript/src/schemas/AgentIssue.ts diff --git a/resources/ts/schemas/AgentIssueModelPath.ts b/paddler_client_javascript/src/schemas/AgentIssueModelPath.ts similarity index 100% rename from resources/ts/schemas/AgentIssueModelPath.ts rename to paddler_client_javascript/src/schemas/AgentIssueModelPath.ts diff --git a/resources/ts/schemas/AgentsResponse.ts b/paddler_client_javascript/src/schemas/AgentsResponse.ts similarity index 100% rename from resources/ts/schemas/AgentsResponse.ts rename to paddler_client_javascript/src/schemas/AgentsResponse.ts diff --git a/resources/ts/schemas/BalancerDesiredState.ts b/paddler_client_javascript/src/schemas/BalancerDesiredState.ts similarity index 100% rename from resources/ts/schemas/BalancerDesiredState.ts rename to paddler_client_javascript/src/schemas/BalancerDesiredState.ts diff --git a/resources/ts/schemas/BufferedRequestsResponse.ts b/paddler_client_javascript/src/schemas/BufferedRequestsResponse.ts similarity index 100% rename from resources/ts/schemas/BufferedRequestsResponse.ts rename to paddler_client_javascript/src/schemas/BufferedRequestsResponse.ts diff --git a/resources/ts/schemas/ChatTemplate.ts b/paddler_client_javascript/src/schemas/ChatTemplate.ts similarity index 100% rename from resources/ts/schemas/ChatTemplate.ts rename to paddler_client_javascript/src/schemas/ChatTemplate.ts diff --git a/paddler_client_javascript/src/schemas/ContinueFromConversationHistoryParams.ts b/paddler_client_javascript/src/schemas/ContinueFromConversationHistoryParams.ts new file mode 100644 index 00000000..6b019e9c --- /dev/null +++ b/paddler_client_javascript/src/schemas/ContinueFromConversationHistoryParams.ts @@ -0,0 +1,21 @@ +import { z } from "zod"; + +import { ConversationMessageSchema } from "./ConversationMessage"; +import { GrammarConstraintSchema } from "./GrammarConstraint"; +import { ToolSchema } from "./Tool"; + +export const ContinueFromConversationHistoryParamsSchema = z + .object({ + add_generation_prompt: z.boolean(), + conversation_history: z.array(ConversationMessageSchema), + enable_thinking: z.boolean(), + grammar: GrammarConstraintSchema.nullable().optional(), + max_tokens: z.number().int(), + parse_tool_calls: z.boolean().optional(), + tools: z.array(ToolSchema).optional(), + }) + .strict(); + +export type ContinueFromConversationHistoryParams = z.infer< + typeof ContinueFromConversationHistoryParamsSchema +>; diff --git a/paddler_client_javascript/src/schemas/ContinueFromRawPromptParams.ts b/paddler_client_javascript/src/schemas/ContinueFromRawPromptParams.ts new file mode 100644 index 00000000..b6716ec1 --- /dev/null +++ b/paddler_client_javascript/src/schemas/ContinueFromRawPromptParams.ts @@ -0,0 +1,15 @@ +import { z } from "zod"; + +import { GrammarConstraintSchema } from "./GrammarConstraint"; + +export const ContinueFromRawPromptParamsSchema = z + .object({ + grammar: GrammarConstraintSchema.nullable().optional(), + max_tokens: z.number().int(), + raw_prompt: z.string(), + }) + .strict(); + +export type ContinueFromRawPromptParams = z.infer< + typeof ContinueFromRawPromptParamsSchema +>; diff --git a/paddler_client_javascript/src/schemas/ConversationMessage.ts b/paddler_client_javascript/src/schemas/ConversationMessage.ts new file mode 100644 index 00000000..64d18333 --- /dev/null +++ b/paddler_client_javascript/src/schemas/ConversationMessage.ts @@ -0,0 +1,13 @@ +import { z } from "zod"; + +import { ConversationMessageContentPartSchema } from "./ConversationMessageContentPart"; + +export const ConversationMessageSchema = z.object({ + role: z.string(), + content: z.union([ + z.string(), + z.array(ConversationMessageContentPartSchema), + ]), +}); + +export type ConversationMessage = z.infer; diff --git a/paddler_client_javascript/src/schemas/ConversationMessageContentPart.ts b/paddler_client_javascript/src/schemas/ConversationMessageContentPart.ts new file mode 100644 index 00000000..1bd08398 --- /dev/null +++ b/paddler_client_javascript/src/schemas/ConversationMessageContentPart.ts @@ -0,0 +1,16 @@ +import { z } from "zod"; + +export const ConversationMessageContentPartSchema = z.union([ + z.object({ + type: z.literal("text"), + text: z.string(), + }), + z.object({ + type: z.literal("image_url"), + image_url: z.object({ url: z.string() }), + }), +]); + +export type ConversationMessageContentPart = z.infer< + typeof ConversationMessageContentPartSchema +>; diff --git a/paddler_client_javascript/src/schemas/Embedding.ts b/paddler_client_javascript/src/schemas/Embedding.ts new file mode 100644 index 00000000..1129536b --- /dev/null +++ b/paddler_client_javascript/src/schemas/Embedding.ts @@ -0,0 +1,13 @@ +import { z } from "zod"; + +import { EmbeddingNormalizationMethodSchema } from "./EmbeddingNormalizationMethod"; +import { PoolingTypeSchema } from "./PoolingType"; + +export const EmbeddingSchema = z.object({ + embedding: z.array(z.number()), + normalization_method: EmbeddingNormalizationMethodSchema, + pooling_type: PoolingTypeSchema, + source_document_id: z.string(), +}); + +export type Embedding = z.infer; diff --git a/paddler_client_javascript/src/schemas/EmbeddingInputDocument.ts b/paddler_client_javascript/src/schemas/EmbeddingInputDocument.ts new file mode 100644 index 00000000..60924aa0 --- /dev/null +++ b/paddler_client_javascript/src/schemas/EmbeddingInputDocument.ts @@ -0,0 +1,10 @@ +import { z } from "zod"; + +export const EmbeddingInputDocumentSchema = z.object({ + content: z.string(), + id: z.string(), +}); + +export type EmbeddingInputDocument = z.infer< + typeof EmbeddingInputDocumentSchema +>; diff --git a/paddler_client_javascript/src/schemas/EmbeddingNormalizationMethod.ts b/paddler_client_javascript/src/schemas/EmbeddingNormalizationMethod.ts new file mode 100644 index 00000000..68a4729a --- /dev/null +++ b/paddler_client_javascript/src/schemas/EmbeddingNormalizationMethod.ts @@ -0,0 +1,15 @@ +import { z } from "zod"; + +export const EmbeddingNormalizationMethodSchema = z.union([ + z.literal("L2"), + z.literal("None"), + z.object({ + RmsNorm: z.object({ + epsilon: z.number(), + }), + }), +]); + +export type EmbeddingNormalizationMethod = z.infer< + typeof EmbeddingNormalizationMethodSchema +>; diff --git a/paddler_client_javascript/src/schemas/GenerateEmbeddingBatchParams.ts b/paddler_client_javascript/src/schemas/GenerateEmbeddingBatchParams.ts new file mode 100644 index 00000000..0d3194d4 --- /dev/null +++ b/paddler_client_javascript/src/schemas/GenerateEmbeddingBatchParams.ts @@ -0,0 +1,13 @@ +import { z } from "zod"; + +import { EmbeddingInputDocumentSchema } from "./EmbeddingInputDocument"; +import { EmbeddingNormalizationMethodSchema } from "./EmbeddingNormalizationMethod"; + +export const GenerateEmbeddingBatchParamsSchema = z.object({ + input_documents: z.array(EmbeddingInputDocumentSchema), + normalization_method: EmbeddingNormalizationMethodSchema, +}); + +export type GenerateEmbeddingBatchParams = z.infer< + typeof GenerateEmbeddingBatchParamsSchema +>; diff --git a/paddler_client_javascript/src/schemas/GrammarConstraint.ts b/paddler_client_javascript/src/schemas/GrammarConstraint.ts new file mode 100644 index 00000000..e2c7a42a --- /dev/null +++ b/paddler_client_javascript/src/schemas/GrammarConstraint.ts @@ -0,0 +1,15 @@ +import { z } from "zod"; + +export const GrammarConstraintSchema = z.discriminatedUnion("type", [ + z.object({ + type: z.literal("gbnf"), + grammar: z.string(), + root: z.string(), + }), + z.object({ + type: z.literal("json_schema"), + schema: z.string(), + }), +]); + +export type GrammarConstraint = z.infer; diff --git a/resources/ts/schemas/HuggingFaceDownloadLock.ts b/paddler_client_javascript/src/schemas/HuggingFaceDownloadLock.ts similarity index 99% rename from resources/ts/schemas/HuggingFaceDownloadLock.ts rename to paddler_client_javascript/src/schemas/HuggingFaceDownloadLock.ts index 24b043e9..1dd570a9 100644 --- a/resources/ts/schemas/HuggingFaceDownloadLock.ts +++ b/paddler_client_javascript/src/schemas/HuggingFaceDownloadLock.ts @@ -1,4 +1,5 @@ import { z } from "zod"; + import { AgentIssueModelPathSchema } from "./AgentIssueModelPath"; export const HuggingFaceDownloadLockSchema = z.object({ diff --git a/resources/ts/schemas/HuggingFaceModelReference.ts b/paddler_client_javascript/src/schemas/HuggingFaceModelReference.ts similarity index 100% rename from resources/ts/schemas/HuggingFaceModelReference.ts rename to paddler_client_javascript/src/schemas/HuggingFaceModelReference.ts diff --git a/resources/ts/schemas/InferenceParameters.ts b/paddler_client_javascript/src/schemas/InferenceParameters.ts similarity index 75% rename from resources/ts/schemas/InferenceParameters.ts rename to paddler_client_javascript/src/schemas/InferenceParameters.ts index ce5cdb6f..ca20c2a4 100644 --- a/resources/ts/schemas/InferenceParameters.ts +++ b/paddler_client_javascript/src/schemas/InferenceParameters.ts @@ -43,14 +43,3 @@ export const InferenceParametersSchema = z .strict(); export type InferenceParameters = z.infer; - -export type BooleanKeys = { - [K in keyof InferenceParameters]: InferenceParameters[K] extends boolean - ? K - : never; -}[keyof InferenceParameters]; -export type NumberKeys = { - [K in keyof InferenceParameters]: InferenceParameters[K] extends number - ? K - : never; -}[keyof InferenceParameters]; diff --git a/paddler_client_javascript/src/schemas/InferenceServiceGenerateTokensResponse.ts b/paddler_client_javascript/src/schemas/InferenceServiceGenerateTokensResponse.ts new file mode 100644 index 00000000..89527836 --- /dev/null +++ b/paddler_client_javascript/src/schemas/InferenceServiceGenerateTokensResponse.ts @@ -0,0 +1,284 @@ +import { z } from "zod"; + +import { ParsedToolCallSchema } from "./ParsedToolCall"; + +export type GeneratedTokenKind = + | "content" + | "reasoning" + | "tool_call" + | "undeterminable"; + +const TokenUsageSchema = z.object({ + prompt_tokens: z.number(), + cached_prompt_tokens: z.number(), + input_image_tokens: z.number(), + input_audio_tokens: z.number(), + content_tokens: z.number(), + reasoning_tokens: z.number(), + tool_call_tokens: z.number(), + undeterminable_tokens: z.number(), +}); + +const GenerationSummarySchema = z.object({ + usage: TokenUsageSchema, +}); + +const GeneratedTokenResultSchema = z.union([ + z.object({ ContentToken: z.string() }), + z.object({ ReasoningToken: z.string() }), + z.object({ ToolCallToken: z.string() }), + z.object({ UndeterminableToken: z.string() }), + z.object({ Done: GenerationSummarySchema }), + z.object({ ChatTemplateError: z.string() }), + z.object({ GrammarIncompatibleWithThinking: z.string() }), + z.object({ GrammarInitializationFailed: z.string() }), + z.object({ GrammarRejectedModelOutput: z.string() }), + z.object({ GrammarSyntaxError: z.string() }), + z.object({ ImageDecodingFailed: z.string() }), + z.object({ MultimodalNotSupported: z.string() }), + z.object({ SamplerError: z.string() }), + z.object({ ToolCallParsed: z.array(ParsedToolCallSchema) }), + z.object({ ToolCallParseFailed: z.string() }), + z.object({ ToolCallValidationFailed: z.array(z.string()) }), + z.object({ ToolCallValidatorBuildFailed: z.string() }), +]); + +type Normalised = + | { + done: true; + error: null; + ok: true; + request_id: string; + summary: z.infer; + token: null; + tokenKind: null; + toolCalls: null; + } + | { + done: false; + error: null; + ok: true; + request_id: string; + summary: null; + token: string; + tokenKind: GeneratedTokenKind; + toolCalls: null; + } + | { + done: false; + error: null; + ok: true; + request_id: string; + summary: null; + token: null; + tokenKind: null; + toolCalls: ReadonlyArray>; + } + | { + done: true; + error: { code: number; description: string }; + ok: false; + request_id: string; + summary: null; + token: null; + tokenKind: null; + toolCalls: null; + } + | { + done: false; + error: { code: number; description: string }; + ok: false; + request_id: string; + summary: null; + token: null; + tokenKind: null; + toolCalls: null; + }; + +function terminalError( + request_id: string, + code: number, + description: string, +): Normalised { + return Object.freeze({ + done: true, + error: Object.freeze({ code, description }), + ok: false, + request_id, + summary: null, + token: null, + tokenKind: null, + toolCalls: null, + }); +} + +function nonTerminalError( + request_id: string, + code: number, + description: string, +): Normalised { + return Object.freeze({ + done: false, + error: Object.freeze({ code, description }), + ok: false, + request_id, + summary: null, + token: null, + tokenKind: null, + toolCalls: null, + }); +} + +function streamingToken( + request_id: string, + token: string, + tokenKind: GeneratedTokenKind, +): Normalised { + return Object.freeze({ + done: false, + error: null, + ok: true, + request_id, + summary: null, + token, + tokenKind, + toolCalls: null, + }); +} + +export const InferenceServiceGenerateTokensResponseSchema = z + .union([ + z.object({ + Error: z.object({ + error: z.object({ + code: z.number(), + description: z.string(), + }), + request_id: z.string(), + }), + }), + z.object({ + Response: z.object({ + request_id: z.string(), + response: z.object({ + GeneratedToken: GeneratedTokenResultSchema, + }), + }), + }), + ]) + .transform(function (data): Normalised { + if ("Error" in data) { + return terminalError( + data.Error.request_id, + data.Error.error.code, + data.Error.error.description, + ); + } + + const request_id = data.Response.request_id; + const variant = data.Response.response.GeneratedToken; + + if ("ContentToken" in variant) { + return streamingToken(request_id, variant.ContentToken, "content"); + } + + if ("ReasoningToken" in variant) { + return streamingToken(request_id, variant.ReasoningToken, "reasoning"); + } + + if ("ToolCallToken" in variant) { + return streamingToken(request_id, variant.ToolCallToken, "tool_call"); + } + + if ("UndeterminableToken" in variant) { + return streamingToken( + request_id, + variant.UndeterminableToken, + "undeterminable", + ); + } + + if ("Done" in variant) { + return Object.freeze({ + done: true, + error: null, + ok: true, + request_id, + summary: variant.Done, + token: null, + tokenKind: null, + toolCalls: null, + }); + } + + if ("ToolCallParsed" in variant) { + return Object.freeze({ + done: false, + error: null, + ok: true, + request_id, + summary: null, + token: null, + tokenKind: null, + toolCalls: Object.freeze(variant.ToolCallParsed), + }); + } + + if ("ToolCallParseFailed" in variant) { + return nonTerminalError(request_id, 422, variant.ToolCallParseFailed); + } + + if ("ToolCallValidationFailed" in variant) { + return nonTerminalError( + request_id, + 422, + variant.ToolCallValidationFailed.join("; "), + ); + } + + if ("ToolCallValidatorBuildFailed" in variant) { + return terminalError( + request_id, + 400, + variant.ToolCallValidatorBuildFailed, + ); + } + + if ("ChatTemplateError" in variant) { + return terminalError(request_id, 500, variant.ChatTemplateError); + } + + if ("GrammarIncompatibleWithThinking" in variant) { + return terminalError( + request_id, + 400, + variant.GrammarIncompatibleWithThinking, + ); + } + + if ("GrammarInitializationFailed" in variant) { + return terminalError(request_id, 500, variant.GrammarInitializationFailed); + } + + if ("GrammarRejectedModelOutput" in variant) { + return terminalError(request_id, 500, variant.GrammarRejectedModelOutput); + } + + if ("GrammarSyntaxError" in variant) { + return terminalError(request_id, 400, variant.GrammarSyntaxError); + } + + if ("ImageDecodingFailed" in variant) { + return terminalError(request_id, 400, variant.ImageDecodingFailed); + } + + if ("MultimodalNotSupported" in variant) { + return terminalError(request_id, 400, variant.MultimodalNotSupported); + } + + return terminalError(request_id, 500, variant.SamplerError); + }); + +export type InferenceServiceGenerateTokensResponse = z.infer< + typeof InferenceServiceGenerateTokensResponseSchema +>; diff --git a/paddler_client_javascript/src/schemas/ModelMetadata.ts b/paddler_client_javascript/src/schemas/ModelMetadata.ts new file mode 100644 index 00000000..3dc4c953 --- /dev/null +++ b/paddler_client_javascript/src/schemas/ModelMetadata.ts @@ -0,0 +1,5 @@ +import { z } from "zod"; + +export const ModelMetadataSchema = z.record(z.string(), z.string()); + +export type ModelMetadata = z.infer; diff --git a/paddler_client_javascript/src/schemas/ParsedToolCall.ts b/paddler_client_javascript/src/schemas/ParsedToolCall.ts new file mode 100644 index 00000000..073ece92 --- /dev/null +++ b/paddler_client_javascript/src/schemas/ParsedToolCall.ts @@ -0,0 +1,15 @@ +import { z } from "zod"; + +export const ToolCallArgumentsSchema = z.union([ + z.strictObject({ InvalidJson: z.string() }), + z.strictObject({ ValidJson: z.unknown() }), +]); + +export const ParsedToolCallSchema = z.object({ + id: z.string(), + name: z.string(), + arguments: ToolCallArgumentsSchema, +}); + +export type ParsedToolCall = z.infer; +export type ToolCallArguments = z.infer; diff --git a/paddler_client_javascript/src/schemas/PoolingType.ts b/paddler_client_javascript/src/schemas/PoolingType.ts new file mode 100644 index 00000000..ed94f496 --- /dev/null +++ b/paddler_client_javascript/src/schemas/PoolingType.ts @@ -0,0 +1,12 @@ +import { z } from "zod"; + +export const PoolingTypeSchema = z.enum([ + "Cls", + "Last", + "Mean", + "None", + "Rank", + "Unspecified", +]); + +export type PoolingType = z.infer; diff --git a/paddler_client_javascript/src/schemas/Tool.ts b/paddler_client_javascript/src/schemas/Tool.ts new file mode 100644 index 00000000..411ff36d --- /dev/null +++ b/paddler_client_javascript/src/schemas/Tool.ts @@ -0,0 +1,20 @@ +import { z } from "zod"; + +import { ValidatedParametersSchemaSchema } from "./ValidatedParametersSchema"; + +export const FunctionDefinitionSchema = z.object({ + name: z.string(), + description: z.string(), + parameters: ValidatedParametersSchemaSchema.optional(), +}); + +export const FunctionCallToolSchema = z.object({ + type: z.literal("function"), + function: FunctionDefinitionSchema, +}); + +export const ToolSchema = FunctionCallToolSchema; + +export type FunctionDefinition = z.infer; +export type FunctionCallTool = z.infer; +export type Tool = z.infer; diff --git a/paddler_client_javascript/src/schemas/ValidatedParametersSchema.ts b/paddler_client_javascript/src/schemas/ValidatedParametersSchema.ts new file mode 100644 index 00000000..fec87b41 --- /dev/null +++ b/paddler_client_javascript/src/schemas/ValidatedParametersSchema.ts @@ -0,0 +1,14 @@ +import { z } from "zod"; + +export const ValidatedParametersSchemaSchema = z + .object({ + type: z.string(), + properties: z.record(z.string(), z.unknown()).optional(), + required: z.array(z.string()).optional(), + additionalProperties: z.unknown().optional(), + }) + .strict(); + +export type ValidatedParametersSchema = z.infer< + typeof ValidatedParametersSchemaSchema +>; diff --git a/paddler_client_javascript/src/streamEventSource.ts b/paddler_client_javascript/src/streamEventSource.ts new file mode 100644 index 00000000..6f9b1c1d --- /dev/null +++ b/paddler_client_javascript/src/streamEventSource.ts @@ -0,0 +1,69 @@ +import { Observable } from "rxjs"; +import type { z } from "zod"; + +import { eventSourceConnectedState } from "./EventSourceConnectedState"; +import { eventSourceConnectionErrorState } from "./EventSourceConnectionErrorState"; +import { eventSourceDeserializationErrorState } from "./EventSourceDeserializationErrorState"; +import { eventSourceInitialState } from "./EventSourceInitialState"; +import type { EventSourceState } from "./EventSourceState"; + +export function streamEventSource({ + url, + schema, +}: { + url: URL | string; + schema: TSchema; +}): Observable> { + return new Observable>(function (subscriber) { + subscriber.next(eventSourceInitialState); + + const eventSource = new EventSource(url); + + eventSource.addEventListener("open", function () { + subscriber.next(eventSourceConnectedState); + }); + + eventSource.addEventListener("error", function () { + subscriber.next(eventSourceConnectionErrorState); + }); + + eventSource.addEventListener("message", function (event) { + if ("string" !== typeof event.data) { + subscriber.next(eventSourceDeserializationErrorState); + + return; + } + + let parsedJson: unknown; + + try { + parsedJson = JSON.parse(event.data); + } catch { + subscriber.next(eventSourceDeserializationErrorState); + + return; + } + + const result = schema.safeParse(parsedJson); + + if (!result.success) { + subscriber.next(eventSourceDeserializationErrorState); + + return; + } + + subscriber.next({ + data: result.data, + isConnected: true, + isConnectionError: false, + isDeserializationError: false, + isInitial: false, + isOk: true, + }); + }); + + return function () { + eventSource.close(); + }; + }); +} diff --git a/paddler_client_javascript/src/streamHttpNdjson.ts b/paddler_client_javascript/src/streamHttpNdjson.ts new file mode 100644 index 00000000..32e886da --- /dev/null +++ b/paddler_client_javascript/src/streamHttpNdjson.ts @@ -0,0 +1,104 @@ +import { Observable } from "rxjs"; +import type { z } from "zod"; + +import { HttpError } from "./HttpError"; +import { JsonError } from "./JsonError"; + +export function streamHttpNdjson({ + url, + body, + signal, + schema, +}: { + url: URL | string; + body: unknown; + signal: AbortSignal; + schema: TSchema; +}): Observable> { + return new Observable(function (subscriber) { + fetch(url, { + body: JSON.stringify(body), + headers: { "Content-Type": "application/json" }, + method: "POST", + signal, + }) + .then(async function (response) { + if (!response.ok) { + throw new HttpError( + response.status, + `HTTP ${response.status} ${response.statusText}`, + ); + } + + if (!response.body) { + throw new HttpError(response.status, "Response has no body"); + } + + const reader = response.body.getReader(); + const decoder = new TextDecoder(); + let buffer = ""; + + while (!signal.aborted) { + const { done, value } = await reader.read(); + + if (done) { + break; + } + + buffer += decoder.decode(value, { stream: true }); + + let newlineIndex = buffer.indexOf("\n"); + + while (newlineIndex !== -1) { + const line = buffer.slice(0, newlineIndex).trim(); + buffer = buffer.slice(newlineIndex + 1); + + if (line.length > 0) { + let parsedJson: unknown; + + try { + parsedJson = JSON.parse(line); + } catch (error: unknown) { + throw new JsonError( + `Failed to parse NDJSON line: ${String(error)}`, + line, + ); + } + + subscriber.next(schema.parse(parsedJson)); + } + + newlineIndex = buffer.indexOf("\n"); + } + } + + const trailing = buffer.trim(); + + if (trailing.length > 0) { + let parsedJson: unknown; + + try { + parsedJson = JSON.parse(trailing); + } catch (error: unknown) { + throw new JsonError( + `Failed to parse trailing NDJSON line: ${String(error)}`, + trailing, + ); + } + + subscriber.next(schema.parse(parsedJson)); + } + + subscriber.complete(); + }) + .catch(function (error: unknown) { + if (signal.aborted) { + subscriber.complete(); + + return; + } + + subscriber.error(error); + }); + }); +} diff --git a/resources/ts/urlToAgentDesiredModel.ts b/paddler_client_javascript/src/urlToAgentDesiredModel.ts similarity index 66% rename from resources/ts/urlToAgentDesiredModel.ts rename to paddler_client_javascript/src/urlToAgentDesiredModel.ts index b094a00c..9372ad44 100644 --- a/resources/ts/urlToAgentDesiredModel.ts +++ b/paddler_client_javascript/src/urlToAgentDesiredModel.ts @@ -1,16 +1,18 @@ import { extractHuggingFaceUrlParts } from "./extractHuggingFaceUrlParts"; -import { type AgentDesiredModel } from "./schemas/AgentDesiredModel"; +import type { AgentDesiredModel } from "./schemas/AgentDesiredModel"; export function urlToAgentDesiredModel(url: URL): AgentDesiredModel { if (url.hostname === "huggingface.co") { return { HuggingFace: extractHuggingFaceUrlParts(url), }; - } else if (url.protocol === "agent:") { + } + + if (url.protocol === "agent:") { return { LocalToAgent: url.pathname, }; - } else { - throw new Error("Unsupported URL format"); } + + throw new Error("Unsupported URL format"); } diff --git a/paddler_client_javascript/src/webSocketProtocol.ts b/paddler_client_javascript/src/webSocketProtocol.ts new file mode 100644 index 00000000..0b3435a7 --- /dev/null +++ b/paddler_client_javascript/src/webSocketProtocol.ts @@ -0,0 +1,3 @@ +export function webSocketProtocol(httpProtocol: string): string { + return httpProtocol === "https:" ? "wss:" : "ws:"; +} diff --git a/paddler_client_javascript/tests/PaddlerError.test.ts b/paddler_client_javascript/tests/PaddlerError.test.ts new file mode 100644 index 00000000..66da35fa --- /dev/null +++ b/paddler_client_javascript/tests/PaddlerError.test.ts @@ -0,0 +1,49 @@ +import test from "ava"; + +import { ConnectionDroppedError } from "../src/ConnectionDroppedError"; +import { HttpError } from "../src/HttpError"; +import { JsonError } from "../src/JsonError"; +import { PaddlerError } from "../src/PaddlerError"; +import { ServerError } from "../src/ServerError"; +import { WebSocketError } from "../src/WebSocketError"; + +test("HttpError extends PaddlerError and carries the status code", function (t) { + const err = new HttpError(503, "Service Unavailable"); + + t.true(err instanceof PaddlerError); + t.true(err instanceof Error); + t.is(err.statusCode, 503); + t.is(err.message, "Service Unavailable"); + t.is(err.name, "HttpError"); +}); + +test("JsonError carries raw payload alongside its message", function (t) { + const err = new JsonError("unexpected token", "{not-json"); + + t.true(err instanceof PaddlerError); + t.is(err.raw, "{not-json"); + t.is(err.name, "JsonError"); +}); + +test("WebSocketError is a distinct subclass", function (t) { + const err = new WebSocketError("socket closed"); + + t.true(err instanceof PaddlerError); + t.is(err.name, "WebSocketError"); +}); + +test("ConnectionDroppedError carries the request id", function (t) { + const err = new ConnectionDroppedError("req-1"); + + t.true(err instanceof PaddlerError); + t.is(err.requestId, "req-1"); + t.true(err.message.includes("req-1")); +}); + +test("ServerError carries an integer code", function (t) { + const err = new ServerError(429, "rate limit"); + + t.true(err instanceof PaddlerError); + t.is(err.code, 429); + t.is(err.message, "rate limit"); +}); diff --git a/paddler_client_javascript/tests/extractHuggingFaceUrlParts.test.ts b/paddler_client_javascript/tests/extractHuggingFaceUrlParts.test.ts new file mode 100644 index 00000000..ce71a2b1 --- /dev/null +++ b/paddler_client_javascript/tests/extractHuggingFaceUrlParts.test.ts @@ -0,0 +1,47 @@ +import test from "ava"; + +import { extractHuggingFaceUrlParts } from "../src/extractHuggingFaceUrlParts"; + +test("blob URL extracts owner, repo, revision and filename", function (t) { + const url = new URL( + "https://huggingface.co/Qwen/Qwen3-0.6B-GGUF/blob/main/Qwen3-0.6B-Q8_0.gguf", + ); + + t.deepEqual(extractHuggingFaceUrlParts(url), { + filename: "Qwen3-0.6B-Q8_0.gguf", + repo_id: "Qwen/Qwen3-0.6B-GGUF", + revision: "main", + }); +}); + +test("resolve URL extracts the same fields", function (t) { + const url = new URL( + "https://huggingface.co/Qwen/Qwen3-0.6B-GGUF/resolve/main/Qwen3-0.6B-Q8_0.gguf", + ); + + t.deepEqual(extractHuggingFaceUrlParts(url), { + filename: "Qwen3-0.6B-Q8_0.gguf", + repo_id: "Qwen/Qwen3-0.6B-GGUF", + revision: "main", + }); +}); + +test("nested filename paths preserve every segment", function (t) { + const url = new URL( + "https://huggingface.co/owner/repo/blob/main/dir/sub/file.gguf", + ); + + t.deepEqual(extractHuggingFaceUrlParts(url), { + filename: "dir/sub/file.gguf", + repo_id: "owner/repo", + revision: "main", + }); +}); + +test("malformed URLs throw", function (t) { + const url = new URL("https://huggingface.co/owner/repo"); + + t.throws(function () { + extractHuggingFaceUrlParts(url); + }); +}); diff --git a/paddler_client_javascript/tests/fetchJson.test.ts b/paddler_client_javascript/tests/fetchJson.test.ts new file mode 100644 index 00000000..3f2014b3 --- /dev/null +++ b/paddler_client_javascript/tests/fetchJson.test.ts @@ -0,0 +1,71 @@ +import test from "ava"; +import { createServer, type RequestListener, type Server } from "node:http"; +import { z } from "zod"; + +import { fetchJson } from "../src/fetchJson"; +import { HttpError } from "../src/HttpError"; + +const Schema = z.object({ ok: z.boolean(), count: z.number() }); + +function listenOnce(handler: RequestListener): Promise<{ + server: Server; + url: URL; +}> { + return new Promise(function (resolve) { + const server = createServer(handler); + + server.listen(0, "127.0.0.1", function () { + const address = server.address(); + + if (typeof address !== "object" || address === null) { + throw new Error("server did not bind"); + } + + resolve({ + server, + url: new URL(`http://127.0.0.1:${address.port}/json`), + }); + }); + }); +} + +test("parses JSON body against the schema", async function (t) { + const { server, url } = await listenOnce(function (_req, res) { + res.writeHead(200, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ ok: true, count: 7 })); + }); + + try { + const result = await fetchJson({ + url, + signal: new AbortController().signal, + schema: Schema, + }); + + t.deepEqual(result, { ok: true, count: 7 }); + } finally { + server.close(); + } +}); + +test("non-2xx status throws HttpError", async function (t) { + const { server, url } = await listenOnce(function (_req, res) { + res.writeHead(404); + res.end(); + }); + + try { + await t.throwsAsync( + async function () { + return fetchJson({ + url, + signal: new AbortController().signal, + schema: Schema, + }); + }, + { instanceOf: HttpError }, + ); + } finally { + server.close(); + } +}); diff --git a/paddler_client_javascript/tests/schemas/Agent.test.ts b/paddler_client_javascript/tests/schemas/Agent.test.ts new file mode 100644 index 00000000..fab857d7 --- /dev/null +++ b/paddler_client_javascript/tests/schemas/Agent.test.ts @@ -0,0 +1,42 @@ +import test from "ava"; + +import { AgentSchema } from "../../src/schemas/Agent"; + +test("parses a fully populated agent payload", function (t) { + const parsed = AgentSchema.parse({ + desired_slots_total: 4, + download_current: 0, + download_filename: null, + download_total: 0, + id: "agent-0", + issues: [], + model_path: "/models/qwen.gguf", + name: "agent-0", + slots_processing: 1, + slots_total: 4, + state_application_status: "Applied", + uses_chat_template_override: false, + }); + + t.is(parsed.id, "agent-0"); + t.is(parsed.state_application_status, "Applied"); +}); + +test("rejects an unknown state_application_status", function (t) { + t.throws(function () { + AgentSchema.parse({ + desired_slots_total: 1, + download_current: 0, + download_filename: null, + download_total: 0, + id: "agent-x", + issues: [], + model_path: null, + name: null, + slots_processing: 0, + slots_total: 1, + state_application_status: "Unknown", + uses_chat_template_override: false, + }); + }); +}); diff --git a/paddler_client_javascript/tests/schemas/InferenceServiceGenerateTokensResponse.test.ts b/paddler_client_javascript/tests/schemas/InferenceServiceGenerateTokensResponse.test.ts new file mode 100644 index 00000000..333b8759 --- /dev/null +++ b/paddler_client_javascript/tests/schemas/InferenceServiceGenerateTokensResponse.test.ts @@ -0,0 +1,86 @@ +import test from "ava"; + +import { InferenceServiceGenerateTokensResponseSchema } from "../../src/schemas/InferenceServiceGenerateTokensResponse"; + +test("ContentToken normalises into a streaming token with content kind", function (t) { + const parsed = InferenceServiceGenerateTokensResponseSchema.parse({ + Response: { + request_id: "req-1", + response: { GeneratedToken: { ContentToken: "Hello" } }, + }, + }); + + t.is(parsed.done, false); + t.is(parsed.error, null); + t.is(parsed.token, "Hello"); + t.is(parsed.tokenKind, "content"); + t.is(parsed.toolCalls, null); +}); + +test("ReasoningToken maps to reasoning kind", function (t) { + const parsed = InferenceServiceGenerateTokensResponseSchema.parse({ + Response: { + request_id: "req-2", + response: { GeneratedToken: { ReasoningToken: "thinking..." } }, + }, + }); + + t.is(parsed.token, "thinking..."); + t.is(parsed.tokenKind, "reasoning"); +}); + +test("Done normalises with the full usage summary", function (t) { + const parsed = InferenceServiceGenerateTokensResponseSchema.parse({ + Response: { + request_id: "req-3", + response: { + GeneratedToken: { + Done: { + usage: { + prompt_tokens: 10, + cached_prompt_tokens: 0, + input_image_tokens: 0, + input_audio_tokens: 0, + content_tokens: 5, + reasoning_tokens: 0, + tool_call_tokens: 0, + undeterminable_tokens: 0, + }, + }, + }, + }, + }, + }); + + t.is(parsed.done, true); + t.is(parsed.error, null); + t.deepEqual(parsed.summary?.usage.prompt_tokens, 10); +}); + +test("ToolCallValidatorBuildFailed normalises to a terminal error", function (t) { + const parsed = InferenceServiceGenerateTokensResponseSchema.parse({ + Response: { + request_id: "req-4", + response: { + GeneratedToken: { + ToolCallValidatorBuildFailed: "schema invalid", + }, + }, + }, + }); + + t.is(parsed.done, true); + t.deepEqual(parsed.error, { code: 400, description: "schema invalid" }); +}); + +test("Top-level Error envelope normalises to terminal error", function (t) { + const parsed = InferenceServiceGenerateTokensResponseSchema.parse({ + Error: { + request_id: "req-5", + error: { code: 500, description: "boom" }, + }, + }); + + t.is(parsed.done, true); + t.deepEqual(parsed.error, { code: 500, description: "boom" }); +}); diff --git a/paddler_client_javascript/tests/schemas/ParsedToolCall.test.ts b/paddler_client_javascript/tests/schemas/ParsedToolCall.test.ts new file mode 100644 index 00000000..534634f0 --- /dev/null +++ b/paddler_client_javascript/tests/schemas/ParsedToolCall.test.ts @@ -0,0 +1,35 @@ +import test from "ava"; + +import { ParsedToolCallSchema } from "../../src/schemas/ParsedToolCall"; + +test("ValidJson arguments parse with the inner JSON kept intact", function (t) { + const parsed = ParsedToolCallSchema.parse({ + id: "call_0", + name: "get_weather", + arguments: { ValidJson: { location: "Paris" } }, + }); + + t.is(parsed.id, "call_0"); + t.is(parsed.name, "get_weather"); + t.deepEqual(parsed.arguments, { ValidJson: { location: "Paris" } }); +}); + +test("InvalidJson arguments preserve the raw string", function (t) { + const parsed = ParsedToolCallSchema.parse({ + id: "call_1", + name: "get_weather", + arguments: { InvalidJson: "not json" }, + }); + + t.deepEqual(parsed.arguments, { InvalidJson: "not json" }); +}); + +test("rejects payloads missing the discriminated arguments wrapper", function (t) { + t.throws(function () { + ParsedToolCallSchema.parse({ + id: "call_2", + name: "get_weather", + arguments: { location: "Paris" }, + }); + }); +}); diff --git a/paddler_client_javascript/tests/streamHttpNdjson.test.ts b/paddler_client_javascript/tests/streamHttpNdjson.test.ts new file mode 100644 index 00000000..a3fc829b --- /dev/null +++ b/paddler_client_javascript/tests/streamHttpNdjson.test.ts @@ -0,0 +1,85 @@ +import test from "ava"; +import { createServer, type RequestListener, type Server } from "node:http"; +import { firstValueFrom, lastValueFrom, toArray } from "rxjs"; +import { z } from "zod"; + +import { HttpError } from "../src/HttpError"; +import { streamHttpNdjson } from "../src/streamHttpNdjson"; + +const Schema = z.object({ index: z.number() }); + +function listenOnce(handler: RequestListener): Promise<{ + server: Server; + url: URL; +}> { + return new Promise(function (resolve) { + const server = createServer(handler); + + server.listen(0, "127.0.0.1", function () { + const address = server.address(); + + if (typeof address !== "object" || address === null) { + throw new Error("server did not bind"); + } + + resolve({ + server, + url: new URL(`http://127.0.0.1:${address.port}/stream`), + }); + }); + }); +} + +test("yields parsed messages from an NDJSON stream", async function (t) { + const { server, url } = await listenOnce(function (_req, res) { + res.writeHead(200, { "Content-Type": "application/x-ndjson" }); + res.write(`${JSON.stringify({ index: 0 })}\n`); + res.write(`${JSON.stringify({ index: 1 })}\n`); + res.write(`${JSON.stringify({ index: 2 })}\n`); + res.end(); + }); + + try { + const messages = await lastValueFrom( + streamHttpNdjson({ + url, + body: {}, + signal: new AbortController().signal, + schema: Schema, + }).pipe(toArray()), + ); + + t.deepEqual(messages, [ + { index: 0 }, + { index: 1 }, + { index: 2 }, + ]); + } finally { + server.close(); + } +}); + +test("non-2xx response throws HttpError", async function (t) { + const { server, url } = await listenOnce(function (_req, res) { + res.writeHead(503); + res.end(); + }); + + try { + await t.throwsAsync( + async function () { + await firstValueFrom( + streamHttpNdjson({ + url, + body: {}, + signal: new AbortController().signal, + schema: Schema, + }), + ); + }, + { instanceOf: HttpError }, + ); + } finally { + server.close(); + } +}); diff --git a/paddler_client_javascript/tests/urlToAgentDesiredModel.test.ts b/paddler_client_javascript/tests/urlToAgentDesiredModel.test.ts new file mode 100644 index 00000000..ce273a9a --- /dev/null +++ b/paddler_client_javascript/tests/urlToAgentDesiredModel.test.ts @@ -0,0 +1,36 @@ +import test from "ava"; + +import { urlToAgentDesiredModel } from "../src/urlToAgentDesiredModel"; + +test("recognizes Hugging Face URLs as HuggingFace variant", function (t) { + const url = new URL( + "https://huggingface.co/Qwen/Qwen3-0.6B-GGUF/blob/main/Qwen3-0.6B-Q8_0.gguf", + ); + + t.deepEqual(urlToAgentDesiredModel(url), { + HuggingFace: { + filename: "Qwen3-0.6B-Q8_0.gguf", + repo_id: "Qwen/Qwen3-0.6B-GGUF", + revision: "main", + }, + }); +}); + +test("agent: URLs become LocalToAgent variant", function (t) { + const url = new URL("agent:///home/user/models/Qwen3-0.6B-Q8_0.gguf"); + + t.deepEqual(urlToAgentDesiredModel(url), { + LocalToAgent: "/home/user/models/Qwen3-0.6B-Q8_0.gguf", + }); +}); + +test("unsupported URLs throw", function (t) { + const url = new URL("https://example.com/some/path"); + + t.throws( + function () { + urlToAgentDesiredModel(url); + }, + { message: "Unsupported URL format" }, + ); +}); diff --git a/paddler_client_javascript/tests/webSocketProtocol.test.ts b/paddler_client_javascript/tests/webSocketProtocol.test.ts new file mode 100644 index 00000000..a80971e7 --- /dev/null +++ b/paddler_client_javascript/tests/webSocketProtocol.test.ts @@ -0,0 +1,15 @@ +import test from "ava"; + +import { webSocketProtocol } from "../src/webSocketProtocol"; + +test("https: maps to wss:", function (t) { + t.is(webSocketProtocol("https:"), "wss:"); +}); + +test("http: maps to ws:", function (t) { + t.is(webSocketProtocol("http:"), "ws:"); +}); + +test("anything other than https: maps to ws:", function (t) { + t.is(webSocketProtocol("file:"), "ws:"); +}); diff --git a/paddler_client_javascript/tsconfig.json b/paddler_client_javascript/tsconfig.json new file mode 100644 index 00000000..0f1ab405 --- /dev/null +++ b/paddler_client_javascript/tsconfig.json @@ -0,0 +1,20 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "Bundler", + "lib": ["ES2022", "DOM"], + "strict": true, + "noUncheckedIndexedAccess": true, + "exactOptionalPropertyTypes": true, + "declaration": true, + "declarationMap": true, + "outDir": "dist", + "rootDir": "src", + "isolatedModules": true, + "skipLibCheck": true, + "resolveJsonModule": true, + "forceConsistentCasingInFileNames": true + }, + "include": ["src/**/*"] +} diff --git a/resources/ts/ConversationMessage.type.ts b/resources/ts/ConversationMessage.type.ts deleted file mode 100644 index accbb3fe..00000000 --- a/resources/ts/ConversationMessage.type.ts +++ /dev/null @@ -1,6 +0,0 @@ -import { type ConversationMessageContentPart } from "./ConversationMessageContentPart.type"; - -export type ConversationMessage = { - role: string; - content: string | ConversationMessageContentPart[]; -}; diff --git a/resources/ts/ConversationMessageContentPart.type.ts b/resources/ts/ConversationMessageContentPart.type.ts deleted file mode 100644 index b99ec455..00000000 --- a/resources/ts/ConversationMessageContentPart.type.ts +++ /dev/null @@ -1,3 +0,0 @@ -export type ConversationMessageContentPart = - | { type: "text"; text: string } - | { type: "image_url"; image_url: { url: string } }; diff --git a/resources/ts/InferenceSocketClient.interface.ts b/resources/ts/InferenceSocketClient.interface.ts deleted file mode 100644 index 3700aac0..00000000 --- a/resources/ts/InferenceSocketClient.interface.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { type Observable } from "rxjs"; - -import { type ConversationMessage } from "./ConversationMessage.type"; -import { type InferenceServiceGenerateTokensResponse } from "./schemas/InferenceServiceGenerateTokensResponse"; - -export interface InferenceSocketClient { - continueConversation(params: { - enableThinking: boolean; - messages: ConversationMessage[]; - }): Observable; -} diff --git a/resources/ts/components/AgentIssues.tsx b/resources/ts/components/AgentIssues.tsx index ceb723e6..013cdf95 100644 --- a/resources/ts/components/AgentIssues.tsx +++ b/resources/ts/components/AgentIssues.tsx @@ -1,7 +1,7 @@ import React from "react"; import { Link } from "wouter"; -import { type AgentIssue } from "../schemas/AgentIssue"; +import { type AgentIssue } from "@intentee/paddler-client/schemas/AgentIssue"; import { agentIssues, agentIssues__issue } from "./AgentIssues.module.css"; diff --git a/resources/ts/components/AgentIssuesPreviewButton.tsx b/resources/ts/components/AgentIssuesPreviewButton.tsx index bb8a3006..f8e9018c 100644 --- a/resources/ts/components/AgentIssuesPreviewButton.tsx +++ b/resources/ts/components/AgentIssuesPreviewButton.tsx @@ -1,6 +1,6 @@ import React, { useCallback, useState, type MouseEvent } from "react"; -import { type AgentIssue } from "../schemas/AgentIssue"; +import { type AgentIssue } from "@intentee/paddler-client/schemas/AgentIssue"; import { AgentIssues } from "./AgentIssues"; import { agentIssuesPreviewButton } from "./AgentIssuesPreviewButton.module.css"; import { ModalWindow } from "./ModalWindow"; diff --git a/resources/ts/components/AgentList.tsx b/resources/ts/components/AgentList.tsx index da18bca9..7b082544 100644 --- a/resources/ts/components/AgentList.tsx +++ b/resources/ts/components/AgentList.tsx @@ -1,7 +1,7 @@ import clsx from "clsx"; import React from "react"; -import { type Agent } from "../schemas/Agent"; +import { type Agent } from "@intentee/paddler-client/schemas/Agent"; import { AgentIssuesPreviewButton } from "./AgentIssuesPreviewButton"; import { AgentListAgentStatus } from "./AgentListAgentStatus"; import { ModelChatTemplateOverridePreviewButton } from "./ModelChatTemplateOverridePreviewButton"; diff --git a/resources/ts/components/AgentListAgentStatus.tsx b/resources/ts/components/AgentListAgentStatus.tsx index 19e30fe1..ceb51f14 100644 --- a/resources/ts/components/AgentListAgentStatus.tsx +++ b/resources/ts/components/AgentListAgentStatus.tsx @@ -1,6 +1,6 @@ import React, { CSSProperties } from "react"; -import { type Agent } from "../schemas/Agent"; +import { type Agent } from "@intentee/paddler-client/schemas/Agent"; import { agentListAgentStatus__progress } from "./AgentListAgentStatus.module.css"; diff --git a/resources/ts/components/AgentListStream.tsx b/resources/ts/components/AgentListStream.tsx index 1937a5f7..69dc27cc 100644 --- a/resources/ts/components/AgentListStream.tsx +++ b/resources/ts/components/AgentListStream.tsx @@ -3,7 +3,7 @@ import React, { useContext } from "react"; import { PaddlerConfigurationContext } from "../contexts/PaddlerConfigurationContext"; import { useEventSourceUpdates } from "../hooks/useEventSourceUpdates"; import { matchEventSourceUpdateState } from "../matchEventSourceUpdateState"; -import { AgentsResponseSchema } from "../schemas/AgentsResponse"; +import { AgentsResponseSchema } from "@intentee/paddler-client/schemas/AgentsResponse"; import { AgentList } from "./AgentList"; import { agentListStream__placeholder } from "./AgentListStream.module.css"; diff --git a/resources/ts/components/BufferedRequestsStream.tsx b/resources/ts/components/BufferedRequestsStream.tsx index 9dfa9dbd..487afca0 100644 --- a/resources/ts/components/BufferedRequestsStream.tsx +++ b/resources/ts/components/BufferedRequestsStream.tsx @@ -3,7 +3,7 @@ import React, { useContext } from "react"; import { PaddlerConfigurationContext } from "../contexts/PaddlerConfigurationContext"; import { useEventSourceUpdates } from "../hooks/useEventSourceUpdates"; import { matchEventSourceUpdateState } from "../matchEventSourceUpdateState"; -import { BufferedRequestsResponseSchema } from "../schemas/BufferedRequestsResponse"; +import { BufferedRequestsResponseSchema } from "@intentee/paddler-client/schemas/BufferedRequestsResponse"; import { BufferedRequests } from "./BufferedRequests"; import { dashboardSectionStreamLoader } from "./dashboardSectionStreamLoader.module.css"; diff --git a/resources/ts/components/ChangeModelForm.tsx b/resources/ts/components/ChangeModelForm.tsx index 1832031d..8e296245 100644 --- a/resources/ts/components/ChangeModelForm.tsx +++ b/resources/ts/components/ChangeModelForm.tsx @@ -11,7 +11,7 @@ import { ChatTemplateContext } from "../contexts/ChatTemplateContext"; import { InferenceParametersContext } from "../contexts/InferenceParametersContext"; import { PaddlerConfigurationContext } from "../contexts/PaddlerConfigurationContext"; import { useAgentDesiredModelUrl } from "../hooks/useAgentDesiredModelUrl"; -import { type BalancerDesiredState } from "../schemas/BalancerDesiredState"; +import { type BalancerDesiredState } from "@intentee/paddler-client/schemas/BalancerDesiredState"; import { ChatTemplateBehavior } from "./ChatTemplateBehavior"; import { InferenceParameterCacheDtype } from "./InferenceParameterCacheDtype"; import { InferenceParameterCheckbox } from "./InferenceParameterCheckbox"; diff --git a/resources/ts/components/ChangeModelPage.tsx b/resources/ts/components/ChangeModelPage.tsx index 0bf5ad75..5aff443e 100644 --- a/resources/ts/components/ChangeModelPage.tsx +++ b/resources/ts/components/ChangeModelPage.tsx @@ -3,7 +3,7 @@ import React, { useContext } from "react"; import { PaddlerConfigurationContext } from "../contexts/PaddlerConfigurationContext"; import { useBalancerDesiredState } from "../hooks/useBalancerDesiredState"; import { matchFetchJsonState } from "../matchFetchJsonState"; -import { type AgentDesiredModel } from "../schemas/AgentDesiredModel"; +import { type AgentDesiredModel } from "@intentee/paddler-client/schemas/AgentDesiredModel"; import { ChangeModelForm } from "./ChangeModelForm"; import { ChatTemplateContextProvider } from "./ChatTemplateContextProvider"; import { FloatingStatus } from "./FloatingStatus"; diff --git a/resources/ts/components/ChatTemplateContextProvider.tsx b/resources/ts/components/ChatTemplateContextProvider.tsx index 10b66ce9..3d7cf35f 100644 --- a/resources/ts/components/ChatTemplateContextProvider.tsx +++ b/resources/ts/components/ChatTemplateContextProvider.tsx @@ -4,7 +4,7 @@ import { ChatTemplateContext, type ChatTemplateContextValue, } from "../contexts/ChatTemplateContext"; -import { type ChatTemplate } from "../schemas/ChatTemplate"; +import { type ChatTemplate } from "@intentee/paddler-client/schemas/ChatTemplate"; export function ChatTemplateContextProvider({ children, diff --git a/resources/ts/components/ChatTemplateEditButton.tsx b/resources/ts/components/ChatTemplateEditButton.tsx index 5f74f3ba..998dd53f 100644 --- a/resources/ts/components/ChatTemplateEditButton.tsx +++ b/resources/ts/components/ChatTemplateEditButton.tsx @@ -8,7 +8,7 @@ import React, { } from "react"; import { ChatTemplateContext } from "../contexts/ChatTemplateContext"; -import { type ChatTemplate } from "../schemas/ChatTemplate"; +import { type ChatTemplate } from "@intentee/paddler-client/schemas/ChatTemplate"; import { CodeEditor } from "./CodeEditor"; import { ModalWindow } from "./ModalWindow"; diff --git a/resources/ts/components/ChatTemplateOverrideLoader.tsx b/resources/ts/components/ChatTemplateOverrideLoader.tsx index 773251ee..348cf8ce 100644 --- a/resources/ts/components/ChatTemplateOverrideLoader.tsx +++ b/resources/ts/components/ChatTemplateOverrideLoader.tsx @@ -2,7 +2,7 @@ import React from "react"; import { useChatTemplateOverride } from "../hooks/useChatTemplateOverride"; import { matchFetchJsonState } from "../matchFetchJsonState"; -import { type Agent } from "../schemas/Agent"; +import { type Agent } from "@intentee/paddler-client/schemas/Agent"; import { CodeEditor } from "./CodeEditor"; import { ModalWindow } from "./ModalWindow"; diff --git a/resources/ts/components/ConversationMessagePromptGeneratedTokens.tsx b/resources/ts/components/ConversationMessagePromptGeneratedTokens.tsx index 56ea2b20..4433b1e5 100644 --- a/resources/ts/components/ConversationMessagePromptGeneratedTokens.tsx +++ b/resources/ts/components/ConversationMessagePromptGeneratedTokens.tsx @@ -4,9 +4,9 @@ import { scan } from "rxjs"; import { PromptContext } from "../contexts/PromptContext"; import { PromptImageContext } from "../contexts/PromptImageContext"; import { PromptThinkingContext } from "../contexts/PromptThinkingContext"; -import { type ConversationMessage as ConversationMessageType } from "../ConversationMessage.type"; -import { InferenceSocketClient } from "../InferenceSocketClient"; -import { type InferenceServiceGenerateTokensResponse } from "../schemas/InferenceServiceGenerateTokensResponse"; +import { inferenceSocketClient } from "@intentee/paddler-client/inferenceSocketClient"; +import { type ConversationMessage as ConversationMessageType } from "@intentee/paddler-client/schemas/ConversationMessage"; +import { type InferenceServiceGenerateTokensResponse } from "@intentee/paddler-client/schemas/InferenceServiceGenerateTokensResponse"; import { ConversationMessage } from "./ConversationMessage"; interface Message { @@ -65,9 +65,9 @@ export const ConversationMessagePromptGeneratedTokens = memo( const { submittedIsThinkingEnabled } = useContext(PromptThinkingContext); const [message, setMessage] = useState(defaultMessage); - const inferenceSocketClient = useMemo( + const socketClient = useMemo( function () { - return InferenceSocketClient({ webSocket }); + return inferenceSocketClient({ webSocket }); }, [webSocket], ); @@ -78,7 +78,7 @@ export const ConversationMessagePromptGeneratedTokens = memo( return; } - const subscription = inferenceSocketClient + const subscription = socketClient .continueConversation({ enableThinking: submittedIsThinkingEnabled, messages: [ @@ -97,17 +97,17 @@ export const ConversationMessagePromptGeneratedTokens = memo( .pipe( scan(function ( message: Message, - { done, error, token }: InferenceServiceGenerateTokensResponse, + chunk: InferenceServiceGenerateTokensResponse, ) { - if (error) { + if (chunk.error) { return Object.freeze({ ...message, - errors: [...message.errors, error], + errors: [...message.errors, chunk.error], isEmpty: false, }); } - if (done) { + if (chunk.done) { return Object.freeze({ errors: message.errors, isEmpty: false, @@ -117,33 +117,25 @@ export const ConversationMessagePromptGeneratedTokens = memo( }); } - if ("" === token) { - return Object.freeze({ - errors: message.errors, - isEmpty: false, - isThinking: true, - response: message.response, - thoughts: message.thoughts, - }); + if (null === chunk.token) { + return message; } - if ("" === token) { + if ("reasoning" === chunk.tokenKind) { return Object.freeze({ errors: message.errors, isEmpty: false, - isThinking: false, + isThinking: true, response: message.response, - thoughts: message.thoughts, + thoughts: `${message.thoughts}${chunk.token}`, }); } - if (message.isThinking) { + if ("tool_call" === chunk.tokenKind) { return Object.freeze({ - errors: message.errors, + ...message, isEmpty: false, - isThinking: true, - response: message.response, - thoughts: `${message.thoughts}${token}`, + isThinking: false, }); } @@ -151,7 +143,7 @@ export const ConversationMessagePromptGeneratedTokens = memo( errors: message.errors, isEmpty: false, isThinking: false, - response: `${message.response}${token}`, + response: `${message.response}${chunk.token}`, thoughts: message.thoughts, }); }, defaultMessage), @@ -163,7 +155,7 @@ export const ConversationMessagePromptGeneratedTokens = memo( }; }, [ - inferenceSocketClient, + socketClient, setMessage, submittedImageDataUri, submittedIsThinkingEnabled, diff --git a/resources/ts/components/InferenceParameterCacheDtype.tsx b/resources/ts/components/InferenceParameterCacheDtype.tsx index 803f0899..36091d77 100644 --- a/resources/ts/components/InferenceParameterCacheDtype.tsx +++ b/resources/ts/components/InferenceParameterCacheDtype.tsx @@ -1,7 +1,7 @@ import React, { useCallback, useContext, type ChangeEvent } from "react"; import { InferenceParametersContext } from "../contexts/InferenceParametersContext"; -import { cacheDtypes } from "../schemas/InferenceParameters"; +import { cacheDtypes } from "@intentee/paddler-client/schemas/InferenceParameters"; import { inferenceParameterInput, inferenceParameterInput__label, diff --git a/resources/ts/components/InferenceParameterCheckbox.tsx b/resources/ts/components/InferenceParameterCheckbox.tsx index 133f3a16..2d80829d 100644 --- a/resources/ts/components/InferenceParameterCheckbox.tsx +++ b/resources/ts/components/InferenceParameterCheckbox.tsx @@ -1,7 +1,7 @@ import React, { useCallback, useContext } from "react"; import { InferenceParametersContext } from "../contexts/InferenceParametersContext"; -import { type BooleanKeys } from "../schemas/InferenceParameters"; +import { type InferenceParametersBooleanKeys } from "../inferenceParametersFormKeys"; import { inferenceParameterInput, inferenceParameterInput__checkbox, @@ -9,7 +9,7 @@ import { } from "./inferenceParameterInput.module.css"; // eslint-disable-next-line @typescript-eslint/no-unnecessary-type-parameters -export function InferenceParameterCheckbox({ +export function InferenceParameterCheckbox({ description, name, }: { diff --git a/resources/ts/components/InferenceParameterInput.tsx b/resources/ts/components/InferenceParameterInput.tsx index 41a05402..fc9c8ae5 100644 --- a/resources/ts/components/InferenceParameterInput.tsx +++ b/resources/ts/components/InferenceParameterInput.tsx @@ -1,10 +1,8 @@ import React, { useCallback, useContext, type FormEvent } from "react"; import { InferenceParametersContext } from "../contexts/InferenceParametersContext"; -import { - type InferenceParameters, - type NumberKeys, -} from "../schemas/InferenceParameters"; +import { type InferenceParameters } from "@intentee/paddler-client/schemas/InferenceParameters"; +import { type InferenceParametersNumberKeys } from "../inferenceParametersFormKeys"; import { inferenceParameterInput, inferenceParameterInput__input, @@ -12,7 +10,7 @@ import { } from "./inferenceParameterInput.module.css"; // eslint-disable-next-line @typescript-eslint/no-unnecessary-type-parameters -export function InferenceParameterInput({ +export function InferenceParameterInput({ description, name, }: { diff --git a/resources/ts/components/InferenceParameterPoolingType.tsx b/resources/ts/components/InferenceParameterPoolingType.tsx index 93799d7b..1882ba6e 100644 --- a/resources/ts/components/InferenceParameterPoolingType.tsx +++ b/resources/ts/components/InferenceParameterPoolingType.tsx @@ -1,7 +1,7 @@ import React, { useCallback, useContext, type ChangeEvent } from "react"; import { InferenceParametersContext } from "../contexts/InferenceParametersContext"; -import { poolingTypes } from "../schemas/InferenceParameters"; +import { poolingTypes } from "@intentee/paddler-client/schemas/InferenceParameters"; import { inferenceParameterInput, inferenceParameterInput__disabledHint, diff --git a/resources/ts/components/InferenceParametersContextProvider.tsx b/resources/ts/components/InferenceParametersContextProvider.tsx index 20b2b1eb..a86150e6 100644 --- a/resources/ts/components/InferenceParametersContextProvider.tsx +++ b/resources/ts/components/InferenceParametersContextProvider.tsx @@ -4,7 +4,7 @@ import { InferenceParametersContext, type InferenceParametersContextValue, } from "../contexts/InferenceParametersContext"; -import { type InferenceParameters } from "../schemas/InferenceParameters"; +import { type InferenceParameters } from "@intentee/paddler-client/schemas/InferenceParameters"; export function InferenceParametersContextProvider({ children, diff --git a/resources/ts/components/ModelChatTemplateOverridePreviewButton.tsx b/resources/ts/components/ModelChatTemplateOverridePreviewButton.tsx index b7e88366..3f87f3e4 100644 --- a/resources/ts/components/ModelChatTemplateOverridePreviewButton.tsx +++ b/resources/ts/components/ModelChatTemplateOverridePreviewButton.tsx @@ -1,6 +1,6 @@ import React, { useCallback, useState, type MouseEvent } from "react"; -import { type Agent } from "../schemas/Agent"; +import { type Agent } from "@intentee/paddler-client/schemas/Agent"; import { ChatTemplateOverrideLoader } from "./ChatTemplateOverrideLoader"; import { modelChatTemplateOverridePreviewButton } from "./ModelChatTemplateOverridePreviewButton.module.css"; diff --git a/resources/ts/components/ModelMetadata.tsx b/resources/ts/components/ModelMetadata.tsx index 2e8a23f5..79b4485b 100644 --- a/resources/ts/components/ModelMetadata.tsx +++ b/resources/ts/components/ModelMetadata.tsx @@ -1,7 +1,7 @@ import React, { useContext } from "react"; import { ModelMetadataContext } from "../contexts/ModelMetadataContext"; -import { type Agent } from "../schemas/Agent"; +import { type Agent } from "@intentee/paddler-client/schemas/Agent"; import { ModalWindow } from "./ModalWindow"; import { ModelChatTemplatePreviewButton } from "./ModelChatTemplatePreviewButton"; import { ModelMetadataFocusedParameter } from "./ModelMetadataFocusedParameter"; diff --git a/resources/ts/components/ModelMetadataLoader.tsx b/resources/ts/components/ModelMetadataLoader.tsx index 81df81b1..706cf763 100644 --- a/resources/ts/components/ModelMetadataLoader.tsx +++ b/resources/ts/components/ModelMetadataLoader.tsx @@ -2,7 +2,7 @@ import React from "react"; import { useModelMetadata } from "../hooks/useModelMetadata"; import { matchFetchJsonState } from "../matchFetchJsonState"; -import { type Agent } from "../schemas/Agent"; +import { type Agent } from "@intentee/paddler-client/schemas/Agent"; import { ModalWindow } from "./ModalWindow"; import { ModelMetadata } from "./ModelMetadata"; import { ModelMetadataContextProvider } from "./ModelMetadataContextProvider"; diff --git a/resources/ts/components/ModelMetadataPreviewButton.tsx b/resources/ts/components/ModelMetadataPreviewButton.tsx index 24c54862..eeb0bfd7 100644 --- a/resources/ts/components/ModelMetadataPreviewButton.tsx +++ b/resources/ts/components/ModelMetadataPreviewButton.tsx @@ -1,6 +1,6 @@ import React, { useCallback, useState, type MouseEvent } from "react"; -import { type Agent } from "../schemas/Agent"; +import { type Agent } from "@intentee/paddler-client/schemas/Agent"; import { ModelMetadataLoader } from "./ModelMetadataLoader"; import { modelMetadataPreviewButton } from "./ModelMetadataPreviewButton.module.css"; diff --git a/resources/ts/components/PromptPage.tsx b/resources/ts/components/PromptPage.tsx index 18145056..25c6682f 100644 --- a/resources/ts/components/PromptPage.tsx +++ b/resources/ts/components/PromptPage.tsx @@ -4,7 +4,7 @@ import { PaddlerConfigurationContext } from "../contexts/PaddlerConfigurationCon import { PromptContext } from "../contexts/PromptContext"; import { useWebSocket } from "../hooks/useWebSocket"; import { matchWebSocketState } from "../matchWebSocketState"; -import { webSocketProtocol } from "../webSocketProtocol"; +import { webSocketProtocol } from "@intentee/paddler-client/webSocketProtocol"; import { ConversationMessage } from "./ConversationMessage"; import { ConversationMessagePromptGeneratedTokens } from "./ConversationMessagePromptGeneratedTokens"; import { ConversationPromptInput } from "./ConversationPromptInput"; diff --git a/resources/ts/contexts/ChatTemplateContext.ts b/resources/ts/contexts/ChatTemplateContext.ts index 7cb79dd8..f330d7fb 100644 --- a/resources/ts/contexts/ChatTemplateContext.ts +++ b/resources/ts/contexts/ChatTemplateContext.ts @@ -1,6 +1,6 @@ import { createContext } from "react"; -import { type ChatTemplate } from "../schemas/ChatTemplate"; +import { type ChatTemplate } from "@intentee/paddler-client/schemas/ChatTemplate"; export type ChatTemplateContextValue = { chatTemplateOverride: null | ChatTemplate; diff --git a/resources/ts/contexts/InferenceParametersContext.ts b/resources/ts/contexts/InferenceParametersContext.ts index 1363f90a..23e29459 100644 --- a/resources/ts/contexts/InferenceParametersContext.ts +++ b/resources/ts/contexts/InferenceParametersContext.ts @@ -1,6 +1,6 @@ import { createContext } from "react"; -import { type InferenceParameters } from "../schemas/InferenceParameters"; +import { type InferenceParameters } from "@intentee/paddler-client/schemas/InferenceParameters"; export type InferenceParametersContextValue = { parameters: InferenceParameters; diff --git a/resources/ts/extractHuggingFaceUrlParts.ts b/resources/ts/extractHuggingFaceUrlParts.ts deleted file mode 100644 index ba35d7f4..00000000 --- a/resources/ts/extractHuggingFaceUrlParts.ts +++ /dev/null @@ -1,46 +0,0 @@ -import { match } from "path-to-regexp"; - -import { type HuggingFaceModelReference } from "./schemas/HuggingFaceModelReference"; - -type UrlParams = { - owner: string; - repo: string; - revision: string; - filename: string; -}; - -const blobMatcher = match("/:owner/:repo/blob/:revision/:filename"); -const resolveMatcher = match( - "/:owner/:repo/resolve/:revision/:filename", -); - -function urlParamsToHuggingFaceUrlParts({ - owner, - repo, - revision, - filename, -}: UrlParams): HuggingFaceModelReference { - return { - filename: filename.startsWith("/") ? filename.slice(1) : filename, - repo_id: `${owner}/${repo}`, - revision, - }; -} - -export function extractHuggingFaceUrlParts({ - pathname, -}: URL): HuggingFaceModelReference { - const blobMatch = blobMatcher(pathname); - - if (blobMatch) { - return urlParamsToHuggingFaceUrlParts(blobMatch.params); - } - - const resolveMatch = resolveMatcher(pathname); - - if (resolveMatch) { - return urlParamsToHuggingFaceUrlParts(resolveMatch.params); - } - - throw new Error(`Invalid Hugging Face URL format: ${pathname}`); -} diff --git a/resources/ts/hooks/useAgentDesiredModelUrl.ts b/resources/ts/hooks/useAgentDesiredModelUrl.ts index d20d691f..dc823592 100644 --- a/resources/ts/hooks/useAgentDesiredModelUrl.ts +++ b/resources/ts/hooks/useAgentDesiredModelUrl.ts @@ -1,7 +1,7 @@ import { useMemo, useState } from "react"; -import { type AgentDesiredModel } from "../schemas/AgentDesiredModel"; -import { urlToAgentDesiredModel } from "../urlToAgentDesiredModel"; +import { type AgentDesiredModel } from "@intentee/paddler-client/schemas/AgentDesiredModel"; +import { urlToAgentDesiredModel } from "@intentee/paddler-client/urlToAgentDesiredModel"; type EmptyState = { agentDesiredModel: "None"; diff --git a/resources/ts/hooks/useBalancerDesiredState.ts b/resources/ts/hooks/useBalancerDesiredState.ts index 03f09e29..b1d8f544 100644 --- a/resources/ts/hooks/useBalancerDesiredState.ts +++ b/resources/ts/hooks/useBalancerDesiredState.ts @@ -1,6 +1,6 @@ import { useCallback } from "react"; -import { BalancerDesiredStateSchema } from "../schemas/BalancerDesiredState"; +import { BalancerDesiredStateSchema } from "@intentee/paddler-client/schemas/BalancerDesiredState"; import { useFetchJson } from "./useFetchJson"; export function useBalancerDesiredState({ diff --git a/resources/ts/hooks/useChatTemplateOverride.ts b/resources/ts/hooks/useChatTemplateOverride.ts index cb66bd63..f875ffd4 100644 --- a/resources/ts/hooks/useChatTemplateOverride.ts +++ b/resources/ts/hooks/useChatTemplateOverride.ts @@ -1,6 +1,6 @@ import { useCallback } from "react"; -import { ChatTemplateSchema } from "../schemas/ChatTemplate"; +import { ChatTemplateSchema } from "@intentee/paddler-client/schemas/ChatTemplate"; import { useFetchJson } from "./useFetchJson"; const responseSchema = ChatTemplateSchema.nullable(); diff --git a/resources/ts/hooks/useEventSourceUpdates.ts b/resources/ts/hooks/useEventSourceUpdates.ts index 30632f2f..b68bb6a9 100644 --- a/resources/ts/hooks/useEventSourceUpdates.ts +++ b/resources/ts/hooks/useEventSourceUpdates.ts @@ -1,93 +1,9 @@ import { useEffect, useState } from "react"; -import { z } from "zod"; +import type { z } from "zod"; -export type ConnectedState = { - data: undefined; - isConnected: true; - isConnectionError: false; - isDeserializationError: false; - isInitial: false; - isOk: false; -}; - -export type ConnectionErrorState = { - data: undefined; - isConnected: false; - isConnectionError: true; - isDeserializationError: false; - isInitial: false; - isOk: false; -}; - -export type DataSnapshotState = { - data: z.infer; - isConnected: true; - isConnectionError: false; - isDeserializationError: false; - isInitial: false; - isOk: true; -}; - -export type DeserializationErrorState = { - data: undefined; - isConnected: true; - isConnectionError: false; - isDeserializationError: true; - isInitial: false; - isOk: false; -}; - -export type InitialStreamState = { - data: undefined; - isConnected: false; - isConnectionError: false; - isDeserializationError: false; - isInitial: true; - isOk: false; -}; - -export type StreamState = - | ConnectedState - | ConnectionErrorState - | DataSnapshotState - | DeserializationErrorState - | InitialStreamState; - -const connectedState: ConnectedState = Object.freeze({ - data: undefined, - isConnected: true, - isConnectionError: false, - isDeserializationError: false, - isInitial: false, - isOk: false, -}); - -const connectionErrorState: ConnectionErrorState = Object.freeze({ - data: undefined, - isConnected: false, - isConnectionError: true, - isDeserializationError: false, - isInitial: false, - isOk: false, -}); - -const deserializationErrorState: DeserializationErrorState = Object.freeze({ - data: undefined, - isConnected: true, - isConnectionError: false, - isDeserializationError: true, - isInitial: false, - isOk: false, -}); - -const defaultStreamState: InitialStreamState = Object.freeze({ - data: undefined, - isConnected: false, - isConnectionError: false, - isDeserializationError: false, - isInitial: true, - isOk: false, -}); +import { eventSourceInitialState } from "@intentee/paddler-client/EventSourceInitialState"; +import { type EventSourceState } from "@intentee/paddler-client/EventSourceState"; +import { streamEventSource } from "@intentee/paddler-client/streamEventSource"; export function useEventSourceUpdates({ endpoint, @@ -95,57 +11,22 @@ export function useEventSourceUpdates({ }: { endpoint: string; schema: TSchema; -}): StreamState { - const [streamState, setStreamState] = - useState>(defaultStreamState); +}): EventSourceState { + const [streamState, setEventSourceState] = + useState>(eventSourceInitialState); useEffect( function () { - const eventSource = new EventSource(endpoint); - - eventSource.addEventListener("error", function () { - setStreamState(connectionErrorState); - }); - - eventSource.addEventListener("message", function (event) { - if ("string" !== typeof event.data) { - console.error("Received non-string data from SSE:", event.data); - setStreamState(deserializationErrorState); - - return; - } - - const parsed = JSON.parse(event.data); - const result = schema.safeParse(parsed); - - if (!result.success) { - console.error( - "Deserialization error:", - JSON.stringify(parsed, null, " "), - result.error.issues, - ); - setStreamState(deserializationErrorState); - } else { - setStreamState({ - data: result.data, - isConnected: true, - isConnectionError: false, - isDeserializationError: false, - isInitial: false, - isOk: true, - }); - } - }); - - eventSource.addEventListener("open", function () { - setStreamState(connectedState); - }); + const subscription = streamEventSource({ + url: endpoint, + schema, + }).subscribe(setEventSourceState); return function () { - eventSource.close(); + subscription.unsubscribe(); }; }, - [endpoint, schema, setStreamState], + [endpoint, schema, setEventSourceState], ); return streamState; diff --git a/resources/ts/hooks/useFetchJson.ts b/resources/ts/hooks/useFetchJson.ts index f1158759..743a2c61 100644 --- a/resources/ts/hooks/useFetchJson.ts +++ b/resources/ts/hooks/useFetchJson.ts @@ -1,59 +1,9 @@ import { useEffect, useState } from "react"; -import { z } from "zod"; +import type { z } from "zod"; -export type EmptyState = { - empty: true; - error: null; - loading: false; - ok: false; - response: null; -}; - -export type ErrorState = { - empty: false; - error: string; - loading: false; - response: null; - ok: false; -}; - -export type LoadingState = { - empty: false; - error: null; - loading: true; - response: null; - ok: false; -}; - -export type SuccessState = { - empty: false; - error: null; - loading: false; - response: TResult; - ok: true; -}; - -export type FetchJsonState = - | EmptyState - | ErrorState - | LoadingState - | SuccessState; - -const emptyState: EmptyState = Object.freeze({ - empty: true, - error: null, - loading: false, - response: null, - ok: false, -}); - -const loadingState: LoadingState = Object.freeze({ - empty: false, - error: null, - loading: true, - response: null, - ok: false, -}); +import { fetchJsonEmptyState } from "@intentee/paddler-client/FetchJsonEmptyState"; +import { fetchJsonLoadingState } from "@intentee/paddler-client/FetchJsonLoadingState"; +import { type FetchJsonState } from "@intentee/paddler-client/FetchJsonState"; export function useFetchJson({ produceFetchPromise, @@ -66,7 +16,7 @@ export function useFetchJson({ responseSchema: TResponseSchema; }): FetchJsonState> { const [fetchState, setFetchState] = - useState>>(loadingState); + useState>>(fetchJsonLoadingState); useEffect( function () { @@ -74,14 +24,14 @@ export function useFetchJson({ const fetchPromise = produceFetchPromise(abortController.signal); if (!fetchPromise) { - setFetchState(emptyState); + setFetchState(fetchJsonEmptyState); return function () { abortController.abort("Fetch promise was not provided."); }; } - setFetchState(loadingState); + setFetchState(fetchJsonLoadingState); fetchPromise .then(function (response) { @@ -99,8 +49,8 @@ export function useFetchJson({ empty: false, error: null, loading: false, - response: result, ok: true, + response: result, }); }) .catch(function (error: unknown) { @@ -108,8 +58,8 @@ export function useFetchJson({ empty: false, error: String(error), loading: false, - response: null, ok: false, + response: null, }); }); diff --git a/resources/ts/hooks/usePrompt.ts b/resources/ts/hooks/usePrompt.ts index 81e69527..e5e68edd 100644 --- a/resources/ts/hooks/usePrompt.ts +++ b/resources/ts/hooks/usePrompt.ts @@ -1,6 +1,7 @@ import { useEffect, useState } from "react"; -import { InferenceServiceGenerateTokensResponseSchema } from "../schemas/InferenceServiceGenerateTokensResponse"; +import { InferenceServiceGenerateTokensResponseSchema } from "@intentee/paddler-client/schemas/InferenceServiceGenerateTokensResponse"; +import { streamHttpNdjson } from "@intentee/paddler-client/streamHttpNdjson"; export function usePrompt({ inferenceAddr, @@ -19,8 +20,9 @@ export function usePrompt({ setMessage(""); - fetch(`//${inferenceAddr}/api/v1/continue_from_conversation_history`, { - body: JSON.stringify({ + const subscription = streamHttpNdjson({ + url: `//${inferenceAddr}/api/v1/continue_from_conversation_history`, + body: { add_generation_prompt: true, conversation_history: [ { role: "assistant", content: systemPrompt }, @@ -28,63 +30,34 @@ export function usePrompt({ ], enable_thinking: false, max_tokens: 300, - }), - headers: { - "Content-Type": "application/json", }, - method: "POST", signal: abortController.signal, - }) - .then(function ({ body }) { - if (!body) { - throw new Error("No response body"); + schema: InferenceServiceGenerateTokensResponseSchema, + }).subscribe({ + next(validatedMessage) { + if (validatedMessage.done) { + return; } - return body.getReader(); - }) - .then(async function (reader) { - const decoder = new TextDecoder(); - - // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition - while (true) { - const { done, value } = await reader.read(); - - if (done || abortController.signal.aborted) { - return; - } - - const chunk = decoder.decode(value, { - stream: true, - }); - - const lines = chunk.split("\n").filter(function (line) { - return line.trim(); - }); - - for (const line of lines) { - try { - const message = JSON.parse(line); - const validatedMessage = - InferenceServiceGenerateTokensResponseSchema.parse(message); - - if (validatedMessage.done) { - return; - } + if (null === validatedMessage.token) { + return; + } - setMessage(function (prevMessage) { - return `${prevMessage}${validatedMessage.token}`; - }); - } catch (err) { - console.error("Error:", err); - } - } + if ("content" !== validatedMessage.tokenKind) { + return; } - }) - .catch(function (error: unknown) { + + setMessage(function (prevMessage) { + return `${prevMessage}${validatedMessage.token}`; + }); + }, + error(error: unknown) { console.error("Error during fetch:", error); - }); + }, + }); return function () { + subscription.unsubscribe(); abortController.abort(); }; }, diff --git a/resources/ts/hooks/useWebSocket.ts b/resources/ts/hooks/useWebSocket.ts index 74afcba9..a76415cc 100644 --- a/resources/ts/hooks/useWebSocket.ts +++ b/resources/ts/hooks/useWebSocket.ts @@ -1,59 +1,9 @@ import { useEffect, useRef, useState } from "react"; -export type ConnectingState = { - isConnected: false; - isConnectionClosed: false; - isConnectionError: false; - webSocket: null; -}; - -export type ConnectionClosedState = { - isConnected: false; - isConnectionClosed: true; - isConnectionError: false; - webSocket: null; -}; - -export type ConnectionErrorState = { - isConnected: false; - isConnectionClosed: false; - isConnectionError: true; - webSocket: null; -}; - -export type ConnectionOpenedState = { - isConnected: true; - isConnectionClosed: false; - isConnectionError: false; - webSocket: WebSocket; -}; - -export type SocketState = - | ConnectingState - | ConnectionClosedState - | ConnectionErrorState - | ConnectionOpenedState; - -const connectionClosedState: ConnectionClosedState = Object.freeze({ - isConnected: false, - isConnectionClosed: true, - isConnectionError: false, - webSocket: null, -}); - -const connectionErrorState: ConnectionErrorState = Object.freeze({ - isConnected: false, - isConnectionClosed: false, - isConnectionError: true, - webSocket: null, -}); - -const defaultSocketState: ConnectingState = Object.freeze({ - isConnected: false, - isConnectionClosed: false, - isConnectionError: false, - webSocket: null, -}); +import { webSocketConnectingState } from "@intentee/paddler-client/WebSocketConnectingState"; +import { webSocketConnectionClosedState } from "@intentee/paddler-client/WebSocketConnectionClosedState"; +import { webSocketConnectionErrorState } from "@intentee/paddler-client/WebSocketConnectionErrorState"; +import { type WebSocketState } from "@intentee/paddler-client/WebSocketState"; const MAX_RECONNECT_DEBOUNCE_TIME_INCREASE = 3; const RECONNECT_DELAY = 600; @@ -62,9 +12,9 @@ function incrementVersion(version: number): number { return version + 1; } -export function useWebSocket({ endpoint }: { endpoint: string }): SocketState { +export function useWebSocket({ endpoint }: { endpoint: string }): WebSocketState { const [socketState, setSocketState] = - useState(defaultSocketState); + useState(webSocketConnectingState); const [version, setVersion] = useState(0); const [webSocket, setWebSocket] = useState(null); const reconnectAttempts = useRef(0); @@ -120,13 +70,13 @@ export function useWebSocket({ endpoint }: { endpoint: string }): SocketState { } webSocket.addEventListener("close", function () { - setSocketState(connectionClosedState); + setSocketState(webSocketConnectionClosedState); setVersion(incrementVersion); }); webSocket.addEventListener("error", function (event) { console.error("WebSocket error:", event); - setSocketState(connectionErrorState); + setSocketState(webSocketConnectionErrorState); setVersion(incrementVersion); }); diff --git a/resources/ts/inferenceParametersFormKeys.ts b/resources/ts/inferenceParametersFormKeys.ts new file mode 100644 index 00000000..9ebc086d --- /dev/null +++ b/resources/ts/inferenceParametersFormKeys.ts @@ -0,0 +1,17 @@ +import type { InferenceParameters } from "@intentee/paddler-client/schemas/InferenceParameters"; + +export type InferenceParametersBooleanKeys = { + [TKey in keyof InferenceParameters]: TKey extends string + ? InferenceParameters[TKey] extends boolean + ? TKey + : never + : never; +}[keyof InferenceParameters]; + +export type InferenceParametersNumberKeys = { + [TKey in keyof InferenceParameters]: TKey extends string + ? InferenceParameters[TKey] extends number + ? TKey + : never + : never; +}[keyof InferenceParameters]; diff --git a/resources/ts/matchEventSourceUpdateState.ts b/resources/ts/matchEventSourceUpdateState.ts index 4883e50a..065549be 100644 --- a/resources/ts/matchEventSourceUpdateState.ts +++ b/resources/ts/matchEventSourceUpdateState.ts @@ -1,24 +1,23 @@ import { type ReactNode } from "react"; import { z } from "zod"; -import { - type ConnectedState, - type ConnectionErrorState, - type DataSnapshotState, - type DeserializationErrorState, - type InitialStreamState, - type StreamState, -} from "./hooks/useEventSourceUpdates"; + +import { type EventSourceConnectedState } from "@intentee/paddler-client/EventSourceConnectedState"; +import { type EventSourceConnectionErrorState } from "@intentee/paddler-client/EventSourceConnectionErrorState"; +import { type EventSourceDataSnapshotState } from "@intentee/paddler-client/EventSourceDataSnapshotState"; +import { type EventSourceDeserializationErrorState } from "@intentee/paddler-client/EventSourceDeserializationErrorState"; +import { type EventSourceInitialState } from "@intentee/paddler-client/EventSourceInitialState"; +import { type EventSourceState } from "@intentee/paddler-client/EventSourceState"; interface Handlers { - connected(state: ConnectedState): ReactNode; - connectionError(state: ConnectionErrorState): ReactNode; - dataSnapshot(state: DataSnapshotState): ReactNode; - deserializationError(state: DeserializationErrorState): ReactNode; - initial(state: InitialStreamState): ReactNode; + connected(state: EventSourceConnectedState): ReactNode; + connectionError(state: EventSourceConnectionErrorState): ReactNode; + dataSnapshot(state: EventSourceDataSnapshotState): ReactNode; + deserializationError(state: EventSourceDeserializationErrorState): ReactNode; + initial(state: EventSourceInitialState): ReactNode; } export function matchEventSourceUpdateState( - streamState: StreamState, + streamState: EventSourceState, handlers: Handlers>, ): ReactNode { if (streamState.isInitial) { diff --git a/resources/ts/matchFetchJsonState.ts b/resources/ts/matchFetchJsonState.ts index c0daa65b..f586f0a1 100644 --- a/resources/ts/matchFetchJsonState.ts +++ b/resources/ts/matchFetchJsonState.ts @@ -1,18 +1,16 @@ import { type ReactNode } from "react"; -import { - type EmptyState, - type ErrorState, - type FetchJsonState, - type LoadingState, - type SuccessState, -} from "./hooks/useFetchJson"; +import { type FetchJsonEmptyState } from "@intentee/paddler-client/FetchJsonEmptyState"; +import { type FetchJsonErrorState } from "@intentee/paddler-client/FetchJsonErrorState"; +import { type FetchJsonLoadingState } from "@intentee/paddler-client/FetchJsonLoadingState"; +import { type FetchJsonState } from "@intentee/paddler-client/FetchJsonState"; +import { type FetchJsonSuccessState } from "@intentee/paddler-client/FetchJsonSuccessState"; interface Handlers { - empty(state: EmptyState): ReactNode; - error(state: ErrorState): ReactNode; - loading(state: LoadingState): ReactNode; - ok(state: SuccessState): ReactNode; + empty(state: FetchJsonEmptyState): ReactNode; + error(state: FetchJsonErrorState): ReactNode; + loading(state: FetchJsonLoadingState): ReactNode; + ok(state: FetchJsonSuccessState): ReactNode; } export function matchFetchJsonState( @@ -27,13 +25,9 @@ export function matchFetchJsonState( return handlers.loading(state); } - if (state.error) { + if (state.error !== null) { return handlers.error(state); } - if (state.ok) { - return handlers.ok(state); - } - - throw new Error(`Invalid state: ${JSON.stringify(state)}`); + return handlers.ok(state); } diff --git a/resources/ts/matchWebSocketState.ts b/resources/ts/matchWebSocketState.ts index 3726e647..2fa81b23 100644 --- a/resources/ts/matchWebSocketState.ts +++ b/resources/ts/matchWebSocketState.ts @@ -1,21 +1,20 @@ import { type ReactNode } from "react"; -import { - type ConnectingState, - type ConnectionClosedState, - type ConnectionErrorState, - type ConnectionOpenedState, - type SocketState, -} from "./hooks/useWebSocket"; + +import { type WebSocketConnectingState } from "@intentee/paddler-client/WebSocketConnectingState"; +import { type WebSocketConnectionClosedState } from "@intentee/paddler-client/WebSocketConnectionClosedState"; +import { type WebSocketConnectionErrorState } from "@intentee/paddler-client/WebSocketConnectionErrorState"; +import { type WebSocketConnectionOpenedState } from "@intentee/paddler-client/WebSocketConnectionOpenedState"; +import { type WebSocketState } from "@intentee/paddler-client/WebSocketState"; interface Handlers { - connected(socketState: ConnectionOpenedState): ReactNode; - connecting(socketState: ConnectingState): ReactNode; - connectionClosed(socketState: ConnectionClosedState): ReactNode; - connectionError(socketState: ConnectionErrorState): ReactNode; + connected(state: WebSocketConnectionOpenedState): ReactNode; + connecting(state: WebSocketConnectingState): ReactNode; + connectionClosed(state: WebSocketConnectionClosedState): ReactNode; + connectionError(state: WebSocketConnectionErrorState): ReactNode; } export function matchWebSocketState( - socketState: SocketState, + socketState: WebSocketState, handlers: Handlers, ): ReactNode { if (socketState.isConnected) { diff --git a/resources/ts/schemas/InferenceServiceGenerateTokensResponse.ts b/resources/ts/schemas/InferenceServiceGenerateTokensResponse.ts deleted file mode 100644 index 2f8bf930..00000000 --- a/resources/ts/schemas/InferenceServiceGenerateTokensResponse.ts +++ /dev/null @@ -1,144 +0,0 @@ -import { z } from "zod"; - -export const InferenceServiceGenerateTokensResponseSchema = z - .union([ - z.object({ - Error: z.object({ - error: z.object({ - code: z.number(), - description: z.string(), - }), - request_id: z.string(), - }), - }), - z.object({ - Response: z.object({ - request_id: z.string(), - response: z.object({ - GeneratedToken: z.union([ - z.object({ - ChatTemplateError: z.string(), - }), - z.literal("Done"), - z.object({ - ImageDecodingFailed: z.string(), - }), - z.object({ - MultimodalNotSupported: z.string(), - }), - z.object({ - Token: z.string(), - }), - ]), - }), - }), - }), - ]) - .transform(function (data): - | { - done: true; - error: null; - ok: true; - request_id: string; - token: null; - } - | { - done: false; - error: null; - ok: true; - request_id: string; - token: string; - } - | { - done: true; - error: { - code: number; - description: string; - }; - ok: false; - request_id: string; - token: null; - } { - if ("Error" in data) { - return Object.freeze({ - done: true, - error: data.Error.error, - ok: false, - request_id: data.Error.request_id, - token: null, - }); - } - - if (data.Response.response.GeneratedToken === "Done") { - return Object.freeze({ - done: true, - error: null, - ok: true, - request_id: data.Response.request_id, - token: null, - }); - } - - if ("ChatTemplateError" in data.Response.response.GeneratedToken) { - return Object.freeze({ - done: true, - error: Object.freeze({ - code: 500, - description: data.Response.response.GeneratedToken.ChatTemplateError, - }), - ok: false, - request_id: data.Response.request_id, - token: null, - }); - } - - if ("ImageDecodingFailed" in data.Response.response.GeneratedToken) { - return Object.freeze({ - done: true, - error: Object.freeze({ - code: 400, - description: - data.Response.response.GeneratedToken.ImageDecodingFailed, - }), - ok: false, - request_id: data.Response.request_id, - token: null, - }); - } - - if ("MultimodalNotSupported" in data.Response.response.GeneratedToken) { - return Object.freeze({ - done: true, - error: Object.freeze({ - code: 400, - description: - data.Response.response.GeneratedToken.MultimodalNotSupported, - }), - ok: false, - request_id: data.Response.request_id, - token: null, - }); - } - - if ("Token" in data.Response.response.GeneratedToken) { - return Object.freeze({ - done: false, - error: null, - ok: true, - request_id: data.Response.request_id, - token: data.Response.response.GeneratedToken.Token, - }); - } - - return Object.freeze({ - done: true, - error: null, - ok: true, - request_id: data.Response.request_id, - token: null, - }); - }); - -export type InferenceServiceGenerateTokensResponse = z.infer< - typeof InferenceServiceGenerateTokensResponseSchema ->; diff --git a/resources/ts/urlToAgentDesiredModel_test.ts b/resources/ts/urlToAgentDesiredModel_test.ts deleted file mode 100644 index 473c7e72..00000000 --- a/resources/ts/urlToAgentDesiredModel_test.ts +++ /dev/null @@ -1,24 +0,0 @@ -import test from "ava"; -import { urlToAgentDesiredModel } from "./urlToAgentDesiredModel"; - -test("recognizes Hugging Face urls", function (test) { - const url = new URL( - "https://huggingface.co/Qwen/Qwen3-0.6B-GGUF/blob/main/Qwen3-0.6B-Q8_0.gguf", - ); - - test.deepEqual(urlToAgentDesiredModel(url), { - HuggingFace: { - filename: "Qwen3-0.6B-Q8_0.gguf", - repo_id: "Qwen/Qwen3-0.6B-GGUF", - revision: "main", - }, - }); -}); - -test("uses local urls", function (test) { - const url = new URL("agent:///home/user/models/Qwen3-0.6B-Q8_0.gguf"); - - test.deepEqual(urlToAgentDesiredModel(url), { - LocalToAgent: "/home/user/models/Qwen3-0.6B-Q8_0.gguf", - }); -}); diff --git a/resources/ts/webSocketProtocol.ts b/resources/ts/webSocketProtocol.ts deleted file mode 100644 index 8f00b582..00000000 --- a/resources/ts/webSocketProtocol.ts +++ /dev/null @@ -1,3 +0,0 @@ -export function webSocketProtocol(windowProtocol: string): string { - return windowProtocol === "https:" ? "wss:" : "ws:"; -} diff --git a/tsconfig.json b/tsconfig.json index e5d0b138..3c5e36d8 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -18,7 +18,7 @@ "esnext.disposable" ], "module": "esnext", - "moduleResolution": "node", + "moduleResolution": "bundler", "noEmit": true, "noFallthroughCasesInSwitch": true, "noImplicitAny": true, From ca5ba9ef063dd40bd9b85ec310582b96d7dba75a Mon Sep 17 00:00:00 2001 From: Mateusz Charytoniuk Date: Wed, 6 May 2026 13:12:38 +0200 Subject: [PATCH 15/51] Surface tool-call schema invalidity as soft event and infrastructure errors as hard Err; collapse image pipeline to one decode --- .../advance_generating_phase.rs | 37 +- .../advance_outcome.rs | 5 +- .../assemble_batch_phase.rs | 4 +- .../agent/continuous_batch_scheduler/mod.rs | 172 +++++--- .../tool_call_pipeline_build_outcome.rs | 7 + .../prepare_conversation_history_request.rs | 3 +- paddler/src/balancer/agent_controller_pool.rs | 6 +- .../http_route/post_chat_completions.rs | 90 ++--- .../api/post_generate_embedding_batch.rs | 22 +- .../src/cancellation_token_stream_guard.rs | 5 +- paddler/src/decoded_image.rs | 375 ++++++++---------- paddler/src/tool_call_event.rs | 7 +- paddler/src/tool_call_parser.rs | 13 +- paddler/src/tool_call_validation_error.rs | 5 +- paddler/src/tool_call_validator.rs | 46 ++- paddler_bootstrap/tests/runners.rs | 5 +- paddler_tests/src/lib.rs | 2 +- ...t_concurrent_requests_independent_usage.rs | 9 +- ...without_parse_flag_emit_only_raw_tokens.rs | 6 +- ...wen3_openai_non_streaming_returns_usage.rs | 5 +- paddler_types/src/generated_token_result.rs | 10 +- .../raw_parameters_schema.rs | 69 ---- 22 files changed, 434 insertions(+), 469 deletions(-) create mode 100644 paddler/src/agent/continuous_batch_scheduler/tool_call_pipeline_build_outcome.rs diff --git a/paddler/src/agent/continuous_batch_scheduler/advance_generating_phase.rs b/paddler/src/agent/continuous_batch_scheduler/advance_generating_phase.rs index 71c400b6..ed7b55c5 100644 --- a/paddler/src/agent/continuous_batch_scheduler/advance_generating_phase.rs +++ b/paddler/src/agent/continuous_batch_scheduler/advance_generating_phase.rs @@ -52,9 +52,11 @@ impl AdvanceGeneratingPhase<'_> { "{:?}: sequence {} sampling exhausted candidates", self.scheduler_context.agent_name, request.sequence_id ); - return Some(AdvanceOutcome::Completed(GeneratedTokenResult::SamplerError( - "all token candidates were eliminated during sampling".to_owned(), - ))); + return Some(AdvanceOutcome::Completed( + GeneratedTokenResult::SamplerError( + "all token candidates were eliminated during sampling".to_owned(), + ), + )); } SampleOutcome::GrammarRejected(message) => { error!( @@ -70,9 +72,9 @@ impl AdvanceGeneratingPhase<'_> { "{:?}: sequence {} sampling error: {message}", self.scheduler_context.agent_name, request.sequence_id ); - return Some(AdvanceOutcome::Completed(GeneratedTokenResult::SamplerError( - message, - ))); + return Some(AdvanceOutcome::Completed( + GeneratedTokenResult::SamplerError(message), + )); } }; @@ -104,9 +106,11 @@ impl AdvanceGeneratingPhase<'_> { "{:?}: sequence {} token_to_piece failed: {message}", self.scheduler_context.agent_name, request.sequence_id ); - return Some(AdvanceOutcome::Completed(GeneratedTokenResult::SamplerError( - format!("Failed to convert token to string: {message}"), - ))); + return Some(AdvanceOutcome::Completed( + GeneratedTokenResult::SamplerError(format!( + "Failed to convert token to string: {message}" + )), + )); } EmitTokenOutcome::ChannelDropped => { warn!( @@ -117,7 +121,8 @@ impl AdvanceGeneratingPhase<'_> { } }; - if let Some(event) = ToolCallPass.run(request.tool_call_pipeline.as_mut(), &classified, &piece) + if let Some(event) = + ToolCallPass.run(request.tool_call_pipeline.as_mut(), &classified, &piece) && request.generated_tokens_tx.send(event).is_err() { warn!( @@ -128,13 +133,11 @@ impl AdvanceGeneratingPhase<'_> { } match completion_phase.run(request, &classified.sampled_token) { - CompletionCheckOutcome::ReachedEog | CompletionCheckOutcome::ReachedMaxTokens => { - Some(AdvanceOutcome::Completed(GeneratedTokenResult::Done( - GenerationSummary { - usage: *request.token_classifier.usage(), - }, - ))) - } + CompletionCheckOutcome::ReachedEog | CompletionCheckOutcome::ReachedMaxTokens => Some( + AdvanceOutcome::Completed(GeneratedTokenResult::Done(GenerationSummary { + usage: *request.token_classifier.usage(), + })), + ), CompletionCheckOutcome::Continue => { Some(AdvanceOutcome::SampledAndStored(classified.sampled_token)) } diff --git a/paddler/src/agent/continuous_batch_scheduler/advance_outcome.rs b/paddler/src/agent/continuous_batch_scheduler/advance_outcome.rs index c62dc3d6..174d4b7e 100644 --- a/paddler/src/agent/continuous_batch_scheduler/advance_outcome.rs +++ b/paddler/src/agent/continuous_batch_scheduler/advance_outcome.rs @@ -16,9 +16,8 @@ mod tests { #[test] fn completed_carries_event_through_into_inner() { - let outcome = AdvanceOutcome::Completed(GeneratedTokenResult::Done( - GenerationSummary::default(), - )); + let outcome = + AdvanceOutcome::Completed(GeneratedTokenResult::Done(GenerationSummary::default())); assert!(matches!( outcome, diff --git a/paddler/src/agent/continuous_batch_scheduler/assemble_batch_phase.rs b/paddler/src/agent/continuous_batch_scheduler/assemble_batch_phase.rs index 32b1ab1c..8466134c 100644 --- a/paddler/src/agent/continuous_batch_scheduler/assemble_batch_phase.rs +++ b/paddler/src/agent/continuous_batch_scheduler/assemble_batch_phase.rs @@ -90,8 +90,8 @@ impl AssembleBatchPhase { continue; } - let chunk = &request.prompt_tokens[request.prompt_tokens_ingested - ..request.prompt_tokens_ingested + chunk_size]; + let chunk = &request.prompt_tokens + [request.prompt_tokens_ingested..request.prompt_tokens_ingested + chunk_size]; let is_last_chunk = request.prompt_tokens_ingested + chunk_size >= request.prompt_tokens.len(); diff --git a/paddler/src/agent/continuous_batch_scheduler/mod.rs b/paddler/src/agent/continuous_batch_scheduler/mod.rs index 5237ca60..ba52b820 100644 --- a/paddler/src/agent/continuous_batch_scheduler/mod.rs +++ b/paddler/src/agent/continuous_batch_scheduler/mod.rs @@ -4,6 +4,7 @@ use std::sync::mpsc::Receiver; use std::sync::mpsc::TryRecvError; use std::time::Duration; +use anyhow::Context as _; use anyhow::Result; use anyhow::anyhow; use llama_cpp_bindings::context::LlamaContext; @@ -61,6 +62,7 @@ pub mod ingesting_contribution; pub mod sample_outcome; pub mod sample_token_phase; pub mod tool_call_pass; +pub mod tool_call_pipeline_build_outcome; use self::advance_generating_phase::AdvanceGeneratingPhase; use self::assemble_batch_phase::AssembleBatchPhase; @@ -68,12 +70,14 @@ use self::batch_pass::BatchPass; use self::commit_phase::CommitPhase; use self::decode_batch_phase::DecodeBatchPhase; use self::decode_outcome::DecodeOutcome; +use crate::agent::continuous_batch_scheduler::tool_call_pipeline_build_outcome::ToolCallPipelineBuildOutcome; use crate::decoded_image::DecodedImage; +use crate::dispenses_slots::DispensesSlots; +use crate::slot_aggregated_status::SlotAggregatedStatus; use crate::tool_call_parser::ToolCallParser; use crate::tool_call_pipeline::ToolCallPipeline; use crate::tool_call_validator::ToolCallValidator; -use crate::dispenses_slots::DispensesSlots; -use crate::slot_aggregated_status::SlotAggregatedStatus; +use crate::tool_call_validator::ValidatorBuildError; pub struct ContinuousBatchScheduler { active_requests: Vec, @@ -225,7 +229,7 @@ impl ContinuousBatchScheduler { parse_tool_calls, tools, } => { - self.accept_text_prompt( + if let Err(err) = self.accept_text_prompt( &raw_prompt, max_tokens, grammar_sampler, @@ -233,7 +237,12 @@ impl ContinuousBatchScheduler { tools, generated_tokens_tx, generate_tokens_stop_rx, - ); + ) { + error!( + "{:?}: failed to accept text prompt: {err:#}", + self.scheduler_context.agent_name + ); + } } PreparedConversationHistoryRequest::MultimodalPrompt { raw_prompt, @@ -245,8 +254,8 @@ impl ContinuousBatchScheduler { } => { let multimodal_context = self.scheduler_context.multimodal_context.clone(); - if let Some(multimodal_context) = multimodal_context.as_ref() { - self.accept_multimodal_request( + if let Some(multimodal_context) = multimodal_context.as_ref() + && let Err(err) = self.accept_multimodal_request( multimodal_context, raw_prompt, &images, @@ -256,6 +265,11 @@ impl ContinuousBatchScheduler { tools, generated_tokens_tx, generate_tokens_stop_rx, + ) + { + error!( + "{:?}: failed to accept multimodal request: {err:#}", + self.scheduler_context.agent_name ); } } @@ -287,7 +301,7 @@ impl ContinuousBatchScheduler { } }; - self.accept_text_prompt( + if let Err(err) = self.accept_text_prompt( &raw_prompt, max_tokens, grammar_sampler, @@ -295,7 +309,12 @@ impl ContinuousBatchScheduler { Vec::new(), generated_tokens_tx, generate_tokens_stop_rx, - ); + ) { + error!( + "{:?}: failed to accept raw prompt: {err:#}", + self.scheduler_context.agent_name + ); + } } fn create_sampler_chain(&mut self) -> LlamaSampler { @@ -357,25 +376,36 @@ impl ContinuousBatchScheduler { &self, tools: Vec>, parse_tool_calls: bool, - ) -> Result, GeneratedTokenResult> { + ) -> Result { if !parse_tool_calls || tools.is_empty() { - return Ok(None); + return Ok(ToolCallPipelineBuildOutcome::Disabled); } - let validator = ToolCallValidator::from_tools(&tools).map_err(|err| { - GeneratedTokenResult::ToolCallValidatorBuildFailed(err.to_string()) - })?; + let validator = match ToolCallValidator::from_tools(&tools) { + Ok(validator) => validator, + Err(ValidatorBuildError::InvalidSchema { tool_name, message }) => { + return Ok(ToolCallPipelineBuildOutcome::SchemaInvalid(format!( + "tool {tool_name:?} parameters are not a valid JSON Schema: {message}" + ))); + } + Err(err @ ValidatorBuildError::SerializationFailed { .. }) => { + return Err(anyhow::Error::from(err)) + .context("failed to serialize tool parameters during validator build"); + } + }; let tools_json: Vec = tools .into_iter() .map(|tool| serde_json::to_value(&tool)) .collect::, _>>() - .map_err(|err| GeneratedTokenResult::ToolCallValidatorBuildFailed(err.to_string()))?; + .context("failed to serialize tools to JSON")?; let parser = ToolCallParser::new(self.scheduler_context.model.clone(), &tools_json) - .map_err(|err| GeneratedTokenResult::ToolCallValidatorBuildFailed(err.to_string()))?; + .context("failed to build tool-call parser")?; - Ok(Some(ToolCallPipeline::new(parser, validator))) + Ok(ToolCallPipelineBuildOutcome::Ready(ToolCallPipeline::new( + parser, validator, + ))) } #[expect( @@ -391,7 +421,33 @@ impl ContinuousBatchScheduler { tools: Vec>, generated_tokens_tx: mpsc::UnboundedSender, generate_tokens_stop_rx: mpsc::UnboundedReceiver<()>, - ) { + ) -> Result<()> { + let tool_call_pipeline = match self + .build_tool_call_pipeline(tools, parse_tool_calls) + .context("failed to build tool-call pipeline for text prompt")? + { + ToolCallPipelineBuildOutcome::Disabled => None, + ToolCallPipelineBuildOutcome::Ready(pipeline) => Some(pipeline), + ToolCallPipelineBuildOutcome::SchemaInvalid(message) => { + error!( + "{:?}: rejecting text prompt: {message}", + self.scheduler_context.agent_name + ); + + if generated_tokens_tx + .send(GeneratedTokenResult::ToolSchemaInvalid(message)) + .is_err() + { + warn!( + "{:?}: failed to send result to client (receiver dropped)", + self.scheduler_context.agent_name + ); + } + + return Ok(()); + } + }; + let mut sequence_id_option = self.sequence_id_pool.acquire(); if sequence_id_option.is_none() { @@ -417,7 +473,7 @@ impl ContinuousBatchScheduler { ); } - return; + return Ok(()); }; let prompt_tokens = match self @@ -445,7 +501,7 @@ impl ContinuousBatchScheduler { ); } - return; + return Ok(()); } }; @@ -454,7 +510,7 @@ impl ContinuousBatchScheduler { else { self.sequence_id_pool.release(sequence_id); - return; + return Ok(()); }; let chain = self.create_sampler_chain(); @@ -480,7 +536,7 @@ impl ContinuousBatchScheduler { ); } - return; + return Ok(()); } }; @@ -508,21 +564,6 @@ impl ContinuousBatchScheduler { prompt_tokens.len() ); - let tool_call_pipeline = match self.build_tool_call_pipeline(tools, parse_tool_calls) { - Ok(pipeline) => pipeline, - Err(event) => { - if generated_tokens_tx.send(event).is_err() { - warn!( - "{:?}: failed to send tool-call validator-build error to client (receiver dropped)", - self.scheduler_context.agent_name - ); - } - self.sequence_id_pool.release(sequence_id); - self.slot_aggregated_status.release_slot(); - return; - } - }; - self.active_requests.push(ContinuousBatchActiveRequest { chain, token_classifier, @@ -540,6 +581,8 @@ impl ContinuousBatchScheduler { tool_call_pipeline, utf8_decoder: encoding_rs::UTF_8.new_decoder(), }); + + Ok(()) } #[expect( @@ -557,7 +600,33 @@ impl ContinuousBatchScheduler { tools: Vec>, generated_tokens_tx: mpsc::UnboundedSender, generate_tokens_stop_rx: mpsc::UnboundedReceiver<()>, - ) { + ) -> Result<()> { + let tool_call_pipeline = match self + .build_tool_call_pipeline(tools, parse_tool_calls) + .context("failed to build tool-call pipeline for multimodal request")? + { + ToolCallPipelineBuildOutcome::Disabled => None, + ToolCallPipelineBuildOutcome::Ready(pipeline) => Some(pipeline), + ToolCallPipelineBuildOutcome::SchemaInvalid(message) => { + error!( + "{:?}: rejecting multimodal request: {message}", + self.scheduler_context.agent_name + ); + + if generated_tokens_tx + .send(GeneratedTokenResult::ToolSchemaInvalid(message)) + .is_err() + { + warn!( + "{:?}: failed to send result to client (receiver dropped)", + self.scheduler_context.agent_name + ); + } + + return Ok(()); + } + }; + let Some(sequence_id) = self.sequence_id_pool.acquire() else { let message = format!( "{:?}: no available sequence slots for multimodal request", @@ -576,7 +645,7 @@ impl ContinuousBatchScheduler { ); } - return; + return Ok(()); }; let bitmaps: Vec = match images @@ -607,7 +676,7 @@ impl ContinuousBatchScheduler { ); } - return; + return Ok(()); } }; @@ -643,7 +712,7 @@ impl ContinuousBatchScheduler { ); } - return; + return Ok(()); } }; @@ -686,7 +755,7 @@ impl ContinuousBatchScheduler { ); } - return; + return Ok(()); } }; @@ -727,7 +796,7 @@ impl ContinuousBatchScheduler { ); } - return; + return Ok(()); } }; @@ -738,7 +807,7 @@ impl ContinuousBatchScheduler { else { self.sequence_id_pool.release(sequence_id); - return; + return Ok(()); }; let chain = self.create_sampler_chain(); @@ -750,21 +819,6 @@ impl ContinuousBatchScheduler { self.scheduler_context.agent_name ); - let tool_call_pipeline = match self.build_tool_call_pipeline(tools, parse_tool_calls) { - Ok(pipeline) => pipeline, - Err(event) => { - if generated_tokens_tx.send(event).is_err() { - warn!( - "{:?}: failed to send tool-call validator-build error to client (receiver dropped)", - self.scheduler_context.agent_name - ); - } - self.sequence_id_pool.release(sequence_id); - self.slot_aggregated_status.release_slot(); - return; - } - }; - self.active_requests.push(ContinuousBatchActiveRequest { chain, token_classifier, @@ -782,6 +836,8 @@ impl ContinuousBatchScheduler { tool_call_pipeline, utf8_decoder: encoding_rs::UTF_8.new_decoder(), }); + + Ok(()) } fn harvest_pending_samples_before_external_decode(&mut self) { diff --git a/paddler/src/agent/continuous_batch_scheduler/tool_call_pipeline_build_outcome.rs b/paddler/src/agent/continuous_batch_scheduler/tool_call_pipeline_build_outcome.rs new file mode 100644 index 00000000..5304d131 --- /dev/null +++ b/paddler/src/agent/continuous_batch_scheduler/tool_call_pipeline_build_outcome.rs @@ -0,0 +1,7 @@ +use crate::tool_call_pipeline::ToolCallPipeline; + +pub enum ToolCallPipelineBuildOutcome { + Disabled, + Ready(ToolCallPipeline), + SchemaInvalid(String), +} diff --git a/paddler/src/agent/prepare_conversation_history_request.rs b/paddler/src/agent/prepare_conversation_history_request.rs index f9470ef2..2e44426c 100644 --- a/paddler/src/agent/prepare_conversation_history_request.rs +++ b/paddler/src/agent/prepare_conversation_history_request.rs @@ -38,8 +38,7 @@ pub fn prepare_conversation_history_request( .iter() .map(|image_url| { DecodedImage::from_data_uri(image_url) - .and_then(|image| image.converted_to_png_if_necessary(image_resize_to_fit)) - .and_then(|image| image.resized_to_fit(image_resize_to_fit)) + .and_then(|image| image.prepared_for_inference(image_resize_to_fit)) }) .collect::, DecodedImageError>>() .map_err(|err| { diff --git a/paddler/src/balancer/agent_controller_pool.rs b/paddler/src/balancer/agent_controller_pool.rs index 29ba23a0..1e74e088 100644 --- a/paddler/src/balancer/agent_controller_pool.rs +++ b/paddler/src/balancer/agent_controller_pool.rs @@ -38,10 +38,8 @@ impl AgentControllerPool { if agent_controller.slots_processing.try_increment_below(limit) { self.update_tx.send_replace(()); - let slot_guard = AgentControllerSlotGuard::new( - agent_controller.clone(), - self.update_tx.clone(), - ); + let slot_guard = + AgentControllerSlotGuard::new(agent_controller.clone(), self.update_tx.clone()); return Some(DispatchedAgent::new(agent_controller, slot_guard)); } diff --git a/paddler/src/balancer/compatibility/openai_service/http_route/post_chat_completions.rs b/paddler/src/balancer/compatibility/openai_service/http_route/post_chat_completions.rs index 00daea82..dc4b3f9e 100644 --- a/paddler/src/balancer/compatibility/openai_service/http_route/post_chat_completions.rs +++ b/paddler/src/balancer/compatibility/openai_service/http_route/post_chat_completions.rs @@ -328,7 +328,8 @@ impl TransformsOutgoingMessage for OpenAIStreamingResponseTransformer { }) => self.handle_content(&request_id, &text), OutgoingMessage::Response(ResponseEnvelope { request_id, - response: OutgoingResponse::GeneratedToken(GeneratedTokenResult::ReasoningToken(text)), + response: + OutgoingResponse::GeneratedToken(GeneratedTokenResult::ReasoningToken(text)), }) => self.handle_reasoning(&request_id, &text), OutgoingMessage::Response(ResponseEnvelope { response: OutgoingResponse::GeneratedToken(GeneratedTokenResult::ToolCallToken(_)), @@ -345,7 +346,9 @@ impl TransformsOutgoingMessage for OpenAIStreamingResponseTransformer { errors, )), .. - }) => Ok(vec![server_error_chunk(&validation_failure_message(&errors))]), + }) => Ok(vec![server_error_chunk(&validation_failure_message( + &errors, + ))]), OutgoingMessage::Response(ResponseEnvelope { request_id, response: OutgoingResponse::GeneratedToken(GeneratedTokenResult::Done(summary)), @@ -362,7 +365,7 @@ impl TransformsOutgoingMessage for OpenAIStreamingResponseTransformer { | GeneratedTokenResult::MultimodalNotSupported(description) | GeneratedTokenResult::SamplerError(description) | GeneratedTokenResult::ToolCallParseFailed(description) - | GeneratedTokenResult::ToolCallValidatorBuildFailed(description), + | GeneratedTokenResult::ToolSchemaInvalid(description), ), .. }) @@ -437,11 +440,7 @@ impl OpenAINonStreamingResponseTransformer { Ok(()) } - fn build_done_chunk( - &self, - request_id: &str, - summary: &GenerationSummary, - ) -> Result { + fn build_done_chunk(&self, request_id: &str, summary: &GenerationSummary) -> Result { let snapshot = self.snapshot_state()?; let has_tool_calls = !snapshot.tool_calls.is_empty(); @@ -464,9 +463,7 @@ impl OpenAINonStreamingResponseTransformer { map.insert("reasoning_content".to_owned(), json!(snapshot.reasoning)); } - if has_tool_calls - && let Some(map) = message_obj.as_object_mut() - { + if has_tool_calls && let Some(map) = message_obj.as_object_mut() { let tool_calls_json: Vec = snapshot .tool_calls .iter() @@ -527,7 +524,8 @@ impl TransformsOutgoingMessage for OpenAINonStreamingResponseTransformer { Ok(vec![]) } OutgoingMessage::Response(ResponseEnvelope { - response: OutgoingResponse::GeneratedToken(GeneratedTokenResult::ReasoningToken(text)), + response: + OutgoingResponse::GeneratedToken(GeneratedTokenResult::ReasoningToken(text)), .. }) => { self.append_reasoning(&text)?; @@ -551,7 +549,9 @@ impl TransformsOutgoingMessage for OpenAINonStreamingResponseTransformer { errors, )), .. - }) => Ok(vec![server_error_chunk(&validation_failure_message(&errors))]), + }) => Ok(vec![server_error_chunk(&validation_failure_message( + &errors, + ))]), OutgoingMessage::Response(ResponseEnvelope { request_id, response: OutgoingResponse::GeneratedToken(GeneratedTokenResult::Done(summary)), @@ -570,7 +570,7 @@ impl TransformsOutgoingMessage for OpenAINonStreamingResponseTransformer { | GeneratedTokenResult::MultimodalNotSupported(description) | GeneratedTokenResult::SamplerError(description) | GeneratedTokenResult::ToolCallParseFailed(description) - | GeneratedTokenResult::ToolCallValidatorBuildFailed(description), + | GeneratedTokenResult::ToolSchemaInvalid(description), ), .. }) @@ -621,9 +621,7 @@ async fn respond( Err(err) => { return Ok(HttpResponse::BadRequest() .content_type("application/json") - .body( - openai_error_json("invalid_request_error", &err.to_string()).to_string(), - )); + .body(openai_error_json("invalid_request_error", &err.to_string()).to_string())); } }; @@ -683,20 +681,16 @@ async fn respond( .body(error_json.clone())); } - let body = results - .into_iter() - .find_map(|result| match result { - TransformResult::Chunk(content) => Some(content), - TransformResult::Discard | TransformResult::Error(_) => None, - }); + let body = results.into_iter().find_map(|result| match result { + TransformResult::Chunk(content) => Some(content), + TransformResult::Discard | TransformResult::Error(_) => None, + }); Ok(body.map_or_else( || { HttpResponse::InternalServerError() .content_type("application/json") - .body( - openai_error_json("server_error", "no completion produced").to_string(), - ) + .body(openai_error_json("server_error", "no completion produced").to_string()) }, |json_body| { HttpResponse::Ok() @@ -850,7 +844,8 @@ mod tests { async fn streaming_reasoning_token_emits_reasoning_content_delta() -> Result<()> { let transformer = streaming_transformer(false); - let message = make_token_message(GeneratedTokenResult::ReasoningToken("thought".to_owned())); + let message = + make_token_message(GeneratedTokenResult::ReasoningToken("thought".to_owned())); let chunks = transformer.transform(message).await?; assert_eq!(chunks.len(), 1); @@ -865,8 +860,9 @@ mod tests { async fn streaming_undeterminable_token_emits_content_delta() -> Result<()> { let transformer = streaming_transformer(false); - let message = - make_token_message(GeneratedTokenResult::UndeterminableToken("ambig".to_owned())); + let message = make_token_message(GeneratedTokenResult::UndeterminableToken( + "ambig".to_owned(), + )); let chunks = transformer.transform(message).await?; assert_eq!(chunks.len(), 1); @@ -896,9 +892,9 @@ mod tests { let transformer = streaming_transformer(false); let chunks = transformer - .transform(make_token_message(GeneratedTokenResult::ToolCallParsed(vec![ - weather_call(), - ]))) + .transform(make_token_message(GeneratedTokenResult::ToolCallParsed( + vec![weather_call()], + ))) .await?; assert_eq!(chunks.len(), 1); @@ -918,9 +914,9 @@ mod tests { let transformer = streaming_transformer(false); transformer - .transform(make_token_message(GeneratedTokenResult::ToolCallParsed(vec![ - weather_call(), - ]))) + .transform(make_token_message(GeneratedTokenResult::ToolCallParsed( + vec![weather_call()], + ))) .await?; let summary = summary_with_counts(2, 0, 0); @@ -996,9 +992,9 @@ mod tests { let transformer = streaming_transformer(false); let chunks = transformer - .transform(make_token_message(GeneratedTokenResult::ToolCallParseFailed( - "bad payload".to_owned(), - ))) + .transform(make_token_message( + GeneratedTokenResult::ToolCallParseFailed("bad payload".to_owned()), + )) .await?; assert_eq!(chunks.len(), 1); @@ -1176,9 +1172,9 @@ mod tests { let transformer = non_streaming_transformer(); transformer - .transform(make_token_message(GeneratedTokenResult::UndeterminableToken( - "amb".to_owned(), - ))) + .transform(make_token_message( + GeneratedTokenResult::UndeterminableToken("amb".to_owned()), + )) .await?; let summary = summary_with_counts(2, 0, 0); @@ -1198,9 +1194,9 @@ mod tests { let transformer = non_streaming_transformer(); transformer - .transform(make_token_message(GeneratedTokenResult::ToolCallParsed(vec![ - weather_call(), - ]))) + .transform(make_token_message(GeneratedTokenResult::ToolCallParsed( + vec![weather_call()], + ))) .await?; let summary = summary_with_counts(4, 0, 0); @@ -1225,9 +1221,9 @@ mod tests { let transformer = non_streaming_transformer(); let chunks = transformer - .transform(make_token_message(GeneratedTokenResult::ToolCallParseFailed( - "bad payload".to_owned(), - ))) + .transform(make_token_message( + GeneratedTokenResult::ToolCallParseFailed("bad payload".to_owned()), + )) .await?; assert_eq!(chunks.len(), 1); diff --git a/paddler/src/balancer/inference_service/http_route/api/post_generate_embedding_batch.rs b/paddler/src/balancer/inference_service/http_route/api/post_generate_embedding_batch.rs index 8b21d83f..f2940e55 100644 --- a/paddler/src/balancer/inference_service/http_route/api/post_generate_embedding_batch.rs +++ b/paddler/src/balancer/inference_service/http_route/api/post_generate_embedding_batch.rs @@ -144,18 +144,16 @@ async fn respond( drop(chunk_tx); - let stream = CancellationTokenStreamGuard::new( - UnboundedReceiverStream::new(chunk_rx), - connection_close, - ) - .filter_map(|transform_result| async move { - match transform_result { - TransformResult::Chunk(content) | TransformResult::Error(content) => { - Some(Ok::<_, Error>(Bytes::from(format!("{content}\n")))) - } - TransformResult::Discard => None, - } - }); + let stream = + CancellationTokenStreamGuard::new(UnboundedReceiverStream::new(chunk_rx), connection_close) + .filter_map(|transform_result| async move { + match transform_result { + TransformResult::Chunk(content) | TransformResult::Error(content) => { + Some(Ok::<_, Error>(Bytes::from(format!("{content}\n")))) + } + TransformResult::Discard => None, + } + }); Ok(HttpResponse::Ok() .insert_header(header::ContentType::json()) diff --git a/paddler/src/cancellation_token_stream_guard.rs b/paddler/src/cancellation_token_stream_guard.rs index 71eb035f..dc836e1b 100644 --- a/paddler/src/cancellation_token_stream_guard.rs +++ b/paddler/src/cancellation_token_stream_guard.rs @@ -25,10 +25,7 @@ where { type Item = TStream::Item; - fn poll_next( - mut self: Pin<&mut Self>, - context: &mut Context<'_>, - ) -> Poll> { + fn poll_next(mut self: Pin<&mut Self>, context: &mut Context<'_>) -> Poll> { Pin::new(&mut self.stream).poll_next(context) } } diff --git a/paddler/src/decoded_image.rs b/paddler/src/decoded_image.rs index 8635a782..1d1d5b74 100644 --- a/paddler/src/decoded_image.rs +++ b/paddler/src/decoded_image.rs @@ -2,6 +2,7 @@ use std::io::Cursor; use base64::Engine as _; use base64::engine::general_purpose::STANDARD as BASE64_STANDARD; +use image::DynamicImage; use image::ImageFormat; use image::imageops::FilterType; use log::info; @@ -38,7 +39,10 @@ fn compute_target_dimension(svg_dim: f64, scale: f64) -> Result Result, DecodedImageError> { +fn rasterize_svg_to_dynamic_image( + data: &[u8], + max_dimension: u32, +) -> Result { let svg_tree = SvgTree::from_data(data, &Options::default()).map_err(|err| { DecodedImageError::ConversionFailed { message: format!("Failed to parse SVG: {err}"), @@ -73,22 +77,60 @@ fn rasterize_svg_to_png(data: &[u8], max_dimension: u32) -> Result, Deco resvg::render(&svg_tree, transform, &mut pixmap.as_mut()); - pixmap - .encode_png() - .map_err(|err| DecodedImageError::ConversionFailed { - message: format!("Failed to encode SVG rasterization to PNG: {err}"), - }) + let rgba = image::RgbaImage::from_raw(target_width, target_height, pixmap.data().to_vec()) + .ok_or_else(|| DecodedImageError::ConversionFailed { + message: "rasterized SVG buffer did not match target dimensions".to_owned(), + })?; + + Ok(DynamicImage::ImageRgba8(rgba)) } -fn reencode_to_png(data: &[u8]) -> Result, DecodedImageError> { - let dynamic_image = +enum LoadedImageOrigin { + PassThroughEligible, + NeedsReencode, +} + +fn load_supported_image( + data: &[u8], + max_dimension: u32, +) -> Result<(DynamicImage, LoadedImageOrigin), DecodedImageError> { + if is_svg(data) { + info!("Rasterizing SVG (max_dimension: {max_dimension})"); + let image = rasterize_svg_to_dynamic_image(data, max_dimension)?; + return Ok((image, LoadedImageOrigin::NeedsReencode)); + } + + let format = image::guess_format(data).map_err(|err| DecodedImageError::ConversionFailed { + message: err.to_string(), + })?; + + let origin = match format { + ImageFormat::Png | ImageFormat::Jpeg | ImageFormat::Gif | ImageFormat::Bmp => { + LoadedImageOrigin::PassThroughEligible + } + unsupported if !unsupported.reading_enabled() => { + return Err(DecodedImageError::UnsupportedFormat { + format: format!("{unsupported:?}"), + }); + } + convertible_format => { + info!("Converting {convertible_format:?} image to PNG for llama.cpp compatibility"); + LoadedImageOrigin::NeedsReencode + } + }; + + let image = image::load_from_memory(data).map_err(|err| DecodedImageError::ConversionFailed { message: err.to_string(), })?; + Ok((image, origin)) +} + +fn encode_png(image: &DynamicImage) -> Result, DecodedImageError> { let mut output_buffer = Cursor::new(Vec::new()); - dynamic_image + image .write_to(&mut output_buffer, ImageFormat::Png) .map_err(|err| DecodedImageError::ConversionFailed { message: err.to_string(), @@ -97,50 +139,24 @@ fn reencode_to_png(data: &[u8]) -> Result, DecodedImageError> { Ok(output_buffer.into_inner()) } +fn encode_jpeg(image: &DynamicImage) -> Result, DecodedImageError> { + let mut output_buffer = Cursor::new(Vec::new()); + + image + .write_to(&mut output_buffer, ImageFormat::Jpeg) + .map_err(|err| DecodedImageError::ResizeFailed { + message: err.to_string(), + })?; + + Ok(output_buffer.into_inner()) +} + #[derive(Debug)] pub struct DecodedImage { pub data: Vec, } impl DecodedImage { - pub fn converted_to_png_if_necessary( - self, - max_dimension: u32, - ) -> Result { - if max_dimension == 0 { - return Err(DecodedImageError::InvalidMaxDimension); - } - - if is_svg(&self.data) { - info!("Converting SVG to PNG (max_dimension: {max_dimension})"); - - let png_data = rasterize_svg_to_png(&self.data, max_dimension)?; - - return Ok(Self { data: png_data }); - } - - let format = - image::guess_format(&self.data).map_err(|err| DecodedImageError::ConversionFailed { - message: err.to_string(), - })?; - - match format { - ImageFormat::Png | ImageFormat::Jpeg | ImageFormat::Gif | ImageFormat::Bmp => Ok(self), - unsupported_format if !unsupported_format.reading_enabled() => { - Err(DecodedImageError::UnsupportedFormat { - format: format!("{unsupported_format:?}"), - }) - } - convertible_format => { - info!("Converting {convertible_format:?} image to PNG for llama.cpp compatibility"); - - let png_data = reencode_to_png(&self.data)?; - - Ok(Self { data: png_data }) - } - } - } - pub fn from_data_uri(image_url: &ImageUrl) -> Result { let url = &image_url.url; @@ -161,36 +177,30 @@ impl DecodedImage { Ok(Self { data }) } - pub fn resized_to_fit(self, max_dimension: u32) -> Result { + pub fn prepared_for_inference(self, max_dimension: u32) -> Result { if max_dimension == 0 { return Err(DecodedImageError::InvalidMaxDimension); } - let dynamic_image = - image::load_from_memory(&self.data).map_err(|err| DecodedImageError::ResizeFailed { - message: err.to_string(), - })?; + let (image, origin) = load_supported_image(&self.data, max_dimension)?; - let width = dynamic_image.width(); - let height = dynamic_image.height(); + let width = image.width(); + let height = image.height(); + let needs_resize = width > max_dimension || height > max_dimension; - if width <= max_dimension && height <= max_dimension { - return Ok(self); + if needs_resize { + let resized = image.resize(max_dimension, max_dimension, FilterType::Lanczos3); + return Ok(Self { + data: encode_jpeg(&resized)?, + }); } - let resized = dynamic_image.resize(max_dimension, max_dimension, FilterType::Lanczos3); - - let mut output_buffer = Cursor::new(Vec::new()); - - resized - .write_to(&mut output_buffer, ImageFormat::Jpeg) - .map_err(|err| DecodedImageError::ResizeFailed { - message: err.to_string(), - })?; - - Ok(Self { - data: output_buffer.into_inner(), - }) + match origin { + LoadedImageOrigin::PassThroughEligible => Ok(self), + LoadedImageOrigin::NeedsReencode => Ok(Self { + data: encode_png(&image)?, + }), + } } } @@ -334,252 +344,193 @@ mod tests { } #[test] - fn test_resized_to_fit_shrinks_oversized_image() -> Result<()> { - let original_data = create_test_jpeg(2000, 1500)?; - let decoded_image = DecodedImage { - data: original_data, - }; - - let resized = decoded_image.resized_to_fit(1024)?; - - let result_image = image::load_from_memory(&resized.data)?; - - assert!(result_image.width() <= 1024); - assert!(result_image.height() <= 1024); - - Ok(()) - } - - #[test] - fn test_resized_to_fit_preserves_aspect_ratio() -> Result<()> { - let original_data = create_test_jpeg(2000, 1000)?; - let decoded_image = DecodedImage { - data: original_data, - }; - - let resized = decoded_image.resized_to_fit(1000)?; - - let result_image = image::load_from_memory(&resized.data)?; - - assert_eq!(result_image.width(), 1000); - assert_eq!(result_image.height(), 500); - - Ok(()) - } - - #[test] - fn test_resized_to_fit_skips_small_image() -> Result<()> { - let original_data = create_test_jpeg(512, 256)?; - let original_len = original_data.len(); - let decoded_image = DecodedImage { - data: original_data, - }; - - let resized = decoded_image.resized_to_fit(1024)?; - - assert_eq!(resized.data.len(), original_len); - - Ok(()) - } - - #[test] - fn test_resized_to_fit_with_llamas_fixture() -> Result<()> { - let fixture_data = std::fs::read(concat!( - env!("CARGO_MANIFEST_DIR"), - "/../fixtures/llamas.jpg" - ))?; - - let original_image = image::load_from_memory(&fixture_data)?; - - assert_eq!(original_image.width(), 640); - assert_eq!(original_image.height(), 427); - - let decoded_image = DecodedImage { data: fixture_data }; - let resized = decoded_image.resized_to_fit(320)?; - - let result_image = image::load_from_memory(&resized.data)?; - - assert_eq!(result_image.width(), 320); - assert_eq!(result_image.height(), 214); - - Ok(()) - } - - #[test] - fn test_converted_to_png_passes_through_jpeg() -> Result<()> { + fn test_prepared_passes_through_small_jpeg() -> Result<()> { let jpeg_data = create_test_jpeg(100, 100)?; let original_len = jpeg_data.len(); - let decoded_image = DecodedImage { data: jpeg_data }; - let result = decoded_image.converted_to_png_if_necessary(1024)?; + let result = decoded_image.prepared_for_inference(1024)?; assert_eq!(result.data.len(), original_len); - Ok(()) } #[test] - fn test_converted_to_png_passes_through_png() -> Result<()> { + fn test_prepared_passes_through_small_png() -> Result<()> { let png_data = create_test_png(100, 100)?; let original_len = png_data.len(); - let decoded_image = DecodedImage { data: png_data }; - let result = decoded_image.converted_to_png_if_necessary(1024)?; + let result = decoded_image.prepared_for_inference(1024)?; assert_eq!(result.data.len(), original_len); - Ok(()) } #[test] - fn test_converted_to_png_passes_through_gif() -> Result<()> { + fn test_prepared_passes_through_small_gif() -> Result<()> { let gif_data = create_test_gif(100, 100)?; let original_len = gif_data.len(); - let decoded_image = DecodedImage { data: gif_data }; - let result = decoded_image.converted_to_png_if_necessary(1024)?; + let result = decoded_image.prepared_for_inference(1024)?; assert_eq!(result.data.len(), original_len); - Ok(()) } #[test] - fn test_converted_to_png_passes_through_bmp() -> Result<()> { + fn test_prepared_passes_through_small_bmp() -> Result<()> { let bmp_data = create_test_bmp(100, 100)?; let original_len = bmp_data.len(); - let decoded_image = DecodedImage { data: bmp_data }; - let result = decoded_image.converted_to_png_if_necessary(1024)?; + let result = decoded_image.prepared_for_inference(1024)?; assert_eq!(result.data.len(), original_len); + Ok(()) + } + + #[test] + fn test_prepared_converts_small_tiff_to_png() -> Result<()> { + let tiff_data = create_test_tiff(100, 100)?; + let decoded_image = DecodedImage { data: tiff_data }; + let result = decoded_image.prepared_for_inference(1024)?; + + let result_format = image::guess_format(&result.data)?; + assert_eq!(result_format, ImageFormat::Png); Ok(()) } #[test] - fn test_converted_to_png_converts_webp_fixture() -> Result<()> { + fn test_prepared_converts_small_webp_fixture_to_png() -> Result<()> { let webp_data = load_fixture("llamas.webp")?; - let decoded_image = DecodedImage { data: webp_data }; - let result = decoded_image.converted_to_png_if_necessary(1024)?; - let result_format = image::guess_format(&result.data)?; + let result = decoded_image.prepared_for_inference(1024)?; + let result_format = image::guess_format(&result.data)?; assert_eq!(result_format, ImageFormat::Png); let result_image = image::load_from_memory(&result.data)?; - assert_eq!(result_image.width(), 640); assert_eq!(result_image.height(), 427); - Ok(()) } #[test] - fn test_converted_to_png_rasterizes_svg_fixture() -> Result<()> { - let svg_data = load_fixture("llamas.svg")?; + fn test_prepared_rasterizes_small_svg() -> Result<()> { + let svg_data = br#" + + "#; + let decoded_image = DecodedImage { + data: svg_data.to_vec(), + }; - let decoded_image = DecodedImage { data: svg_data }; - let result = decoded_image.converted_to_png_if_necessary(320)?; + let result = decoded_image.prepared_for_inference(1024)?; let result_format = image::guess_format(&result.data)?; - assert_eq!(result_format, ImageFormat::Png); let result_image = image::load_from_memory(&result.data)?; - - assert!(result_image.width() <= 320); - assert!(result_image.height() <= 320); - + assert_eq!(result_image.width(), 50); + assert_eq!(result_image.height(), 50); Ok(()) } #[test] - fn test_converted_to_png_rejects_zero_max_dimension() -> Result<()> { + fn test_prepared_rasterizes_svg_fixture_within_bound() -> Result<()> { let svg_data = load_fixture("llamas.svg")?; - let decoded_image = DecodedImage { data: svg_data }; - let result = decoded_image.converted_to_png_if_necessary(0); + let result = decoded_image.prepared_for_inference(320)?; + + let result_format = image::guess_format(&result.data)?; + let result_image = image::load_from_memory(&result.data)?; + + assert!(result_image.width() <= 320); + assert!(result_image.height() <= 320); assert!(matches!( - result, - Err(DecodedImageError::InvalidMaxDimension) + result_format, + ImageFormat::Png | ImageFormat::Jpeg )); - Ok(()) } #[test] - fn test_resized_to_fit_rejects_zero_max_dimension() -> Result<()> { - let original_data = create_test_jpeg(200, 100)?; - let decoded_image = DecodedImage { - data: original_data, - }; + fn test_prepared_resizes_oversized_jpeg_to_jpeg() -> Result<()> { + let jpeg_data = create_test_jpeg(2000, 1500)?; + let decoded_image = DecodedImage { data: jpeg_data }; - let result = decoded_image.resized_to_fit(0); + let result = decoded_image.prepared_for_inference(1024)?; - assert!(matches!( - result, - Err(DecodedImageError::InvalidMaxDimension) - )); + let result_format = image::guess_format(&result.data)?; + assert_eq!(result_format, ImageFormat::Jpeg); + let result_image = image::load_from_memory(&result.data)?; + assert!(result_image.width() <= 1024); + assert!(result_image.height() <= 1024); Ok(()) } #[test] - fn test_converted_to_png_converts_tiff() -> Result<()> { - let tiff_data = create_test_tiff(100, 100)?; - - let decoded_image = DecodedImage { data: tiff_data }; - let result = decoded_image.converted_to_png_if_necessary(1024)?; + fn test_prepared_preserves_aspect_ratio_on_resize() -> Result<()> { + let jpeg_data = create_test_jpeg(2000, 1000)?; + let decoded_image = DecodedImage { data: jpeg_data }; - let result_format = image::guess_format(&result.data)?; - - assert_eq!(result_format, ImageFormat::Png); + let result = decoded_image.prepared_for_inference(1000)?; + let result_image = image::load_from_memory(&result.data)?; + assert_eq!(result_image.width(), 1000); + assert_eq!(result_image.height(), 500); Ok(()) } #[test] - fn test_converted_to_png_rasterizes_small_svg() -> Result<()> { - let svg_data = br#" - - "#; - - let decoded_image = DecodedImage { - data: svg_data.to_vec(), - }; - - let result = decoded_image.converted_to_png_if_necessary(1024)?; + fn test_prepared_with_jpg_fixture_within_bound() -> Result<()> { + let fixture_data = std::fs::read(concat!( + env!("CARGO_MANIFEST_DIR"), + "/../fixtures/llamas.jpg" + ))?; - let result_format = image::guess_format(&result.data)?; + let original_image = image::load_from_memory(&fixture_data)?; + assert_eq!(original_image.width(), 640); + assert_eq!(original_image.height(), 427); - assert_eq!(result_format, ImageFormat::Png); + let decoded_image = DecodedImage { data: fixture_data }; + let result = decoded_image.prepared_for_inference(320)?; let result_image = image::load_from_memory(&result.data)?; + assert_eq!(result_image.width(), 320); + assert_eq!(result_image.height(), 214); + Ok(()) + } - assert_eq!(result_image.width(), 50); - assert_eq!(result_image.height(), 50); + #[test] + fn test_prepared_rejects_zero_max_dimension() -> Result<()> { + let png_data = create_test_png(50, 50)?; + let decoded_image = DecodedImage { data: png_data }; + let result = decoded_image.prepared_for_inference(0); + + assert!(matches!( + result, + Err(DecodedImageError::InvalidMaxDimension) + )); Ok(()) } #[test] - fn test_converted_to_png_rejects_zero_dimension_svg() { + fn test_prepared_rejects_zero_dimension_svg() { let svg_data = br#" "#; - let decoded_image = DecodedImage { data: svg_data.to_vec(), }; - let result = decoded_image.converted_to_png_if_necessary(1024); + let result = decoded_image.prepared_for_inference(1024); assert!(matches!( result, diff --git a/paddler/src/tool_call_event.rs b/paddler/src/tool_call_event.rs index 3d7cd7ec..53af576a 100644 --- a/paddler/src/tool_call_event.rs +++ b/paddler/src/tool_call_event.rs @@ -65,9 +65,10 @@ mod tests { #[test] fn validation_failed_classifies_as_failure() { - let event = ToolCallEvent::ValidationFailed(vec![ - ToolCallValidationError::UnknownToolName("x".to_owned()), - ]); + let event = + ToolCallEvent::ValidationFailed(vec![ToolCallValidationError::UnknownToolName( + "x".to_owned(), + )]); assert!(event.is_failure()); assert!(!event.is_resolved()); diff --git a/paddler/src/tool_call_parser.rs b/paddler/src/tool_call_parser.rs index 6b151908..bd1777e8 100644 --- a/paddler/src/tool_call_parser.rs +++ b/paddler/src/tool_call_parser.rs @@ -30,18 +30,19 @@ impl ToolCallParser { return Err(ToolCallParseError::EmptyInput); } - Ok(self.model.parse_chat_message(&self.tools_json, input, false)?) + Ok(self + .model + .parse_chat_message(&self.tools_json, input, false)?) } - pub fn parse_partial( - &self, - input: &str, - ) -> Result { + pub fn parse_partial(&self, input: &str) -> Result { if input.is_empty() { return Err(ToolCallParseError::EmptyInput); } - Ok(self.model.parse_chat_message(&self.tools_json, input, true)?) + Ok(self + .model + .parse_chat_message(&self.tools_json, input, true)?) } #[must_use] diff --git a/paddler/src/tool_call_validation_error.rs b/paddler/src/tool_call_validation_error.rs index 7f7c9d58..798cf38c 100644 --- a/paddler/src/tool_call_validation_error.rs +++ b/paddler/src/tool_call_validation_error.rs @@ -3,8 +3,5 @@ pub enum ToolCallValidationError { #[error("unknown tool name {0:?}")] UnknownToolName(String), #[error("arguments for tool {tool_name:?} failed schema check: {message}")] - SchemaMismatch { - tool_name: String, - message: String, - }, + SchemaMismatch { tool_name: String, message: String }, } diff --git a/paddler/src/tool_call_validator.rs b/paddler/src/tool_call_validator.rs index fdbc8ce4..4aa1cf49 100644 --- a/paddler/src/tool_call_validator.rs +++ b/paddler/src/tool_call_validator.rs @@ -62,9 +62,10 @@ impl ToolCallValidator { } pub fn validate(&self, parsed: &ParsedToolCall) -> Result<(), ToolCallValidationError> { - let strategy = self.strategies.get(&parsed.name).ok_or_else(|| { - ToolCallValidationError::UnknownToolName(parsed.name.clone()) - })?; + let strategy = self + .strategies + .get(&parsed.name) + .ok_or_else(|| ToolCallValidationError::UnknownToolName(parsed.name.clone()))?; let arguments_value = match &parsed.arguments { ToolCallArguments::ValidJson(value) => value, @@ -245,10 +246,8 @@ mod tests { #[test] fn known_tool_names_returns_all_registered_names() -> Result<()> { - let validator = ToolCallValidator::from_tools(&[ - weather_tool_with_schema(), - schemaless_tool(), - ])?; + let validator = + ToolCallValidator::from_tools(&[weather_tool_with_schema(), schemaless_tool()])?; let mut names = validator.known_tool_names(); names.sort_unstable(); @@ -309,4 +308,37 @@ mod tests { } } } + + fn tool_with_invalid_additional_properties_schema() -> Tool { + Tool::Function(FunctionCall { + function: Function { + name: "broken_additional".to_owned(), + description: "tool whose additionalProperties schema is invalid".to_owned(), + parameters: Parameters::Schema(ValidatedParametersSchema { + schema_type: "object".to_owned(), + properties: None, + required: None, + additional_properties: Some(json!({"type": "not_a_type"})), + }), + }, + }) + } + + #[test] + fn invalid_additional_properties_schema_rejects_validator_build() -> Result<()> { + let error = + ToolCallValidator::from_tools(&[tool_with_invalid_additional_properties_schema()]) + .err() + .ok_or_else(|| anyhow::anyhow!("expected ValidatorBuildError, got Ok"))?; + + match error { + super::ValidatorBuildError::InvalidSchema { tool_name, .. } => { + assert_eq!(tool_name, "broken_additional"); + Ok(()) + } + super::ValidatorBuildError::SerializationFailed { .. } => { + bail!("expected InvalidSchema, got SerializationFailed: {error:?}"); + } + } + } } diff --git a/paddler_bootstrap/tests/runners.rs b/paddler_bootstrap/tests/runners.rs index 588f9214..3567cb91 100644 --- a/paddler_bootstrap/tests/runners.rs +++ b/paddler_bootstrap/tests/runners.rs @@ -227,10 +227,7 @@ async fn agent_runner_cancels_from_parent_token() -> Result<()> { let parent = CancellationToken::new(); - let runner = AgentRunner::start(make_agent_runner_params( - management_addr, - parent.clone(), - )); + let runner = AgentRunner::start(make_agent_runner_params(management_addr, parent.clone())); parent.cancel(); drop(runner); diff --git a/paddler_tests/src/lib.rs b/paddler_tests/src/lib.rs index fd7d0e14..475a496a 100644 --- a/paddler_tests/src/lib.rs +++ b/paddler_tests/src/lib.rs @@ -14,10 +14,10 @@ pub mod current_test_device; pub mod in_process_cluster_params; pub mod inference_http_client; pub mod inference_message_stream; -pub mod openai_chat_completions_client; pub mod load_test_image_data_uri; pub mod make_agent_controller_without_remote_agent; pub mod model_card; +pub mod openai_chat_completions_client; pub mod paddler_command; pub mod parse_test_device_value; pub mod spawn_agent_subprocess; diff --git a/paddler_tests/tests/qwen3_internal_endpoint_concurrent_requests_independent_usage.rs b/paddler_tests/tests/qwen3_internal_endpoint_concurrent_requests_independent_usage.rs index b8a48da3..6f8154da 100644 --- a/paddler_tests/tests/qwen3_internal_endpoint_concurrent_requests_independent_usage.rs +++ b/paddler_tests/tests/qwen3_internal_endpoint_concurrent_requests_independent_usage.rs @@ -37,7 +37,7 @@ async fn qwen3_internal_endpoint_concurrent_requests_keep_independent_usage() -> enable_thinking: false, grammar: None, max_tokens: 30, - parse_tool_calls: false, + parse_tool_calls: false, tools: vec![], }) .await?; @@ -49,14 +49,15 @@ async fn qwen3_internal_endpoint_concurrent_requests_keep_independent_usage() -> .last() .ok_or_else(|| anyhow::anyhow!("no token results received"))?; match last { - GeneratedTokenResult::Done(summary) => Ok::(*summary), + GeneratedTokenResult::Done(summary) => { + Ok::(*summary) + } other => Err(anyhow::anyhow!("last result was not Done: {other:?}")), } } }); - let summaries: Vec = - future::try_join_all(futures).await?; + let summaries: Vec = future::try_join_all(futures).await?; assert_eq!(summaries.len(), 2); diff --git a/paddler_tests/tests/qwen3_internal_endpoint_tools_without_parse_flag_emit_only_raw_tokens.rs b/paddler_tests/tests/qwen3_internal_endpoint_tools_without_parse_flag_emit_only_raw_tokens.rs index cc097baf..8385840d 100644 --- a/paddler_tests/tests/qwen3_internal_endpoint_tools_without_parse_flag_emit_only_raw_tokens.rs +++ b/paddler_tests/tests/qwen3_internal_endpoint_tools_without_parse_flag_emit_only_raw_tokens.rs @@ -67,10 +67,10 @@ async fn qwen3_internal_endpoint_tools_without_parse_flag_emit_only_raw_tokens() match event { GeneratedTokenResult::ToolCallParsed(_) | GeneratedTokenResult::ToolCallParseFailed(_) - | GeneratedTokenResult::ToolCallValidationFailed(_) - | GeneratedTokenResult::ToolCallValidatorBuildFailed(_) => { + | GeneratedTokenResult::ToolSchemaInvalid(_) + | GeneratedTokenResult::ToolCallValidationFailed(_) => { anyhow::bail!( - "expected no parsed/parse-failed/validation-failed/validator-build-failed events when parse_tool_calls=false, got: {event:?}" + "expected no parsed/parse-failed/schema-invalid/validation-failed events when parse_tool_calls=false, got: {event:?}" ); } _ => {} diff --git a/paddler_tests/tests/qwen3_openai_non_streaming_returns_usage.rs b/paddler_tests/tests/qwen3_openai_non_streaming_returns_usage.rs index c7a28a93..8627b746 100644 --- a/paddler_tests/tests/qwen3_openai_non_streaming_returns_usage.rs +++ b/paddler_tests/tests/qwen3_openai_non_streaming_returns_usage.rs @@ -50,7 +50,10 @@ async fn qwen3_openai_non_streaming_returns_usage() -> Result<()> { .and_then(Value::as_str) .ok_or_else(|| anyhow::anyhow!("non-streaming response missing message content"))?; - assert!(!content.is_empty(), "non-streaming content must not be empty"); + assert!( + !content.is_empty(), + "non-streaming content must not be empty" + ); cluster.shutdown().await?; diff --git a/paddler_types/src/generated_token_result.rs b/paddler_types/src/generated_token_result.rs index 26eac8d3..03af3ae6 100644 --- a/paddler_types/src/generated_token_result.rs +++ b/paddler_types/src/generated_token_result.rs @@ -23,7 +23,7 @@ pub enum GeneratedTokenResult { ToolCallParsed(Vec), ToolCallToken(String), ToolCallValidationFailed(Vec), - ToolCallValidatorBuildFailed(String), + ToolSchemaInvalid(String), UndeterminableToken(String), } @@ -77,7 +77,7 @@ impl StreamableResult for GeneratedTokenResult { | Self::ImageDecodingFailed(_) | Self::MultimodalNotSupported(_) | Self::SamplerError(_) - | Self::ToolCallValidatorBuildFailed(_) + | Self::ToolSchemaInvalid(_) ) } } @@ -132,10 +132,8 @@ mod tests { } #[test] - fn tool_call_validator_build_failed_is_done() { - assert!( - GeneratedTokenResult::ToolCallValidatorBuildFailed("bad schema".to_owned()).is_done() - ); + fn tool_schema_invalid_is_done() { + assert!(GeneratedTokenResult::ToolSchemaInvalid("invalid schema".to_owned()).is_done()); } #[test] diff --git a/paddler_types/src/request_params/continue_from_conversation_history_params/tool/tool_params/function_call/parameters_schema/raw_parameters_schema.rs b/paddler_types/src/request_params/continue_from_conversation_history_params/tool/tool_params/function_call/parameters_schema/raw_parameters_schema.rs index 4fb4b907..023a6680 100644 --- a/paddler_types/src/request_params/continue_from_conversation_history_params/tool/tool_params/function_call/parameters_schema/raw_parameters_schema.rs +++ b/paddler_types/src/request_params/continue_from_conversation_history_params/tool/tool_params/function_call/parameters_schema/raw_parameters_schema.rs @@ -1,6 +1,5 @@ use anyhow::Result; use anyhow::anyhow; -use jsonschema::validator_for; use serde::Deserialize; use serde::Serialize; use serde_json::Map; @@ -9,12 +8,6 @@ use serde_json::Value; use super::validated_parameters_schema::ValidatedParametersSchema; use crate::validates::Validates; -fn validate_schema(schema: &Value) -> Result<()> { - validator_for(schema).map_err(|err| anyhow!("{err}"))?; - - Ok(()) -} - #[derive(Default, Deserialize, Serialize)] #[serde(deny_unknown_fields)] pub struct RawParametersSchema { @@ -36,20 +29,6 @@ impl Validates for RawParametersSchema { } } - if let Some(ref properties) = self.properties { - for (key, schema) in properties { - validate_schema(schema) - .map_err(|err| anyhow!("Invalid schema for property '{key}': {err}"))?; - } - } - - if let Some(ref additional) = self.additional_properties - && !additional.is_boolean() - { - validate_schema(additional) - .map_err(|err| anyhow!("Invalid additionalProperties schema: {err}"))?; - } - Ok(ValidatedParametersSchema { schema_type: self.schema_type, properties: self.properties, @@ -96,31 +75,6 @@ mod tests { Ok(()) } - #[test] - fn test_deserialize_with_invalid_property_schema() -> Result<()> { - let input = json!({ - "type": "object", - "properties": { - "name": {"type": "invalid_type"} - } - }); - - let raw_schema: RawParametersSchema = from_value(input)?; - let result: Result = raw_schema.validate(); - - assert!(result.is_err()); - - if let Err(error) = &result { - assert!( - error - .to_string() - .contains("Invalid schema for property 'name'") - ); - } - - Ok(()) - } - #[test] fn test_deserialize_required_field_not_in_properties() -> Result<()> { let input = json!({ @@ -146,27 +100,4 @@ mod tests { Ok(()) } - - #[test] - fn test_deserialize_invalid_additional_properties_schema() -> Result<()> { - let input = json!({ - "type": "object", - "additionalProperties": {"type": "not_a_type"} - }); - - let raw_schema: RawParametersSchema = from_value(input)?; - let result: Result = raw_schema.validate(); - - assert!(result.is_err()); - - if let Err(error) = &result { - assert!( - error - .to_string() - .contains("Invalid additionalProperties schema") - ); - } - - Ok(()) - } } From 1cf5e373a863a0176f4a131118f2a26be7e2d67e Mon Sep 17 00:00:00 2001 From: Mateusz Charytoniuk Date: Wed, 6 May 2026 22:19:38 +0200 Subject: [PATCH 16/51] Drop llama-cpp-bindings type re-exports; add per-model thinking and tool-call internal tests --- ...tinue_from_conversation_history_request.rs | 2 +- .../agent/continuous_batch_active_request.rs | 3 +- .../advance_generating_phase.rs | 67 ++++----- .../classified_token.rs | 4 + .../classify_token_phase.rs | 38 +++-- .../emit_token_phase.rs | 22 +-- .../agent/continuous_batch_scheduler/mod.rs | 135 +++++++----------- .../tool_call_pass.rs | 1 + .../notification_params/set_state_params.rs | 3 +- paddler/src/agent/jsonrpc/request.rs | 2 +- .../agent/management_socket_client_service.rs | 2 +- .../prepare_conversation_history_request.rs | 2 +- paddler/src/agent/reconciliation_service.rs | 2 +- paddler/src/agent_desired_state.rs | 2 +- paddler/src/balancer/agent_controller.rs | 4 +- paddler/src/balancer/agent_controller_pool.rs | 2 +- .../http_route/post_chat_completions.rs | 14 +- ...post_continue_from_conversation_history.rs | 2 +- paddler/src/balancer_applicable_state.rs | 2 +- .../src/balancer_applicable_state_holder.rs | 2 +- paddler/src/lib.rs | 2 - paddler/src/sets_desired_state.rs | 3 +- paddler/src/tool_call_event.rs | 4 +- paddler/src/tool_call_pipeline.rs | 2 +- paddler/src/tool_call_validator.rs | 10 +- .../src/bootstrapped_agent_handle.rs | 2 +- paddler_cli/src/main.rs | 8 +- paddler_client/src/client_inference.rs | 2 +- paddler_client/src/lib.rs | 12 +- paddler_gui/src/running_balancer_snapshot.rs | 2 +- paddler_tests/src/inference_http_client.rs | 2 +- paddler_tests/src/lib.rs | 3 + .../src/model_card/gemma_4_e4b_it.rs | 15 ++ .../model_card/ministral_3_14b_reasoning.rs | 15 ++ paddler_tests/src/model_card/mod.rs | 7 +- .../src/model_card/qwen3_6_35b_a3b.rs | 15 ++ .../start_in_process_cluster_with_gemma_4.rs | 35 +++++ ...art_in_process_cluster_with_ministral_3.rs | 37 +++++ .../start_in_process_cluster_with_qwen3_6.rs | 35 +++++ paddler_tests/src/test_device.rs | 6 +- ...onversation_with_function_tool_succeeds.rs | 2 +- ...l_with_invalid_required_field_in_schema.rs | 2 +- ...in_flight_inference_during_model_switch.rs | 35 ++++- ...nternal_endpoint_emits_reasoning_tokens.rs | 102 +++++++++++++ ...l_endpoint_emits_tool_call_parsed_event.rs | 105 ++++++++++++++ ...king_disabled_emits_only_content_tokens.rs | 132 +++++++++++++++++ ...nternal_endpoint_emits_reasoning_tokens.rs | 102 +++++++++++++ ...l_endpoint_emits_tool_call_parsed_event.rs | 105 ++++++++++++++ ...king_disabled_emits_only_content_tokens.rs | 133 +++++++++++++++++ ...l_endpoint_emits_tool_call_parsed_event.rs | 105 ++++++++++++++ ...king_disabled_emits_only_content_tokens.rs | 132 +++++++++++++++++ ...thinking_enabled_emits_reasoning_tokens.rs | 102 +++++++++++++ ...l_endpoint_emits_tool_call_parsed_event.rs | 105 ++++++++++++++ ...king_disabled_emits_only_content_tokens.rs | 132 +++++++++++++++++ ...thinking_enabled_emits_reasoning_tokens.rs | 102 +++++++++++++ ...l_endpoint_emits_tool_call_parsed_event.rs | 8 +- ...nternal_endpoint_emits_tool_call_tokens.rs | 2 +- ...without_parse_flag_emit_only_raw_tokens.rs | 2 +- paddler_types/src/generated_token_result.rs | 3 +- paddler_types/src/generation_summary.rs | 2 +- paddler_types/src/inference_server/request.rs | 2 +- paddler_types/src/lib.rs | 10 -- .../tool/mod.rs | 4 +- .../tool/tool_params/mod.rs | 2 - paddler_types/src/request_params/mod.rs | 1 - 65 files changed, 1739 insertions(+), 221 deletions(-) create mode 100644 paddler_tests/src/model_card/gemma_4_e4b_it.rs create mode 100644 paddler_tests/src/model_card/ministral_3_14b_reasoning.rs create mode 100644 paddler_tests/src/model_card/qwen3_6_35b_a3b.rs create mode 100644 paddler_tests/src/start_in_process_cluster_with_gemma_4.rs create mode 100644 paddler_tests/src/start_in_process_cluster_with_ministral_3.rs create mode 100644 paddler_tests/src/start_in_process_cluster_with_qwen3_6.rs create mode 100644 paddler_tests/tests/gemma4_internal_endpoint_emits_reasoning_tokens.rs create mode 100644 paddler_tests/tests/gemma4_internal_endpoint_emits_tool_call_parsed_event.rs create mode 100644 paddler_tests/tests/gemma4_internal_endpoint_with_thinking_disabled_emits_only_content_tokens.rs create mode 100644 paddler_tests/tests/mistral3_internal_endpoint_emits_reasoning_tokens.rs create mode 100644 paddler_tests/tests/mistral3_internal_endpoint_emits_tool_call_parsed_event.rs create mode 100644 paddler_tests/tests/mistral3_internal_endpoint_with_thinking_disabled_emits_only_content_tokens.rs create mode 100644 paddler_tests/tests/qwen35_internal_endpoint_emits_tool_call_parsed_event.rs create mode 100644 paddler_tests/tests/qwen35_internal_endpoint_with_thinking_disabled_emits_only_content_tokens.rs create mode 100644 paddler_tests/tests/qwen35_internal_endpoint_with_thinking_enabled_emits_reasoning_tokens.rs create mode 100644 paddler_tests/tests/qwen36_internal_endpoint_emits_tool_call_parsed_event.rs create mode 100644 paddler_tests/tests/qwen36_internal_endpoint_with_thinking_disabled_emits_only_content_tokens.rs create mode 100644 paddler_tests/tests/qwen36_internal_endpoint_with_thinking_enabled_emits_reasoning_tokens.rs diff --git a/paddler/src/agent/continue_from_conversation_history_request.rs b/paddler/src/agent/continue_from_conversation_history_request.rs index af68c877..6bbcec8e 100644 --- a/paddler/src/agent/continue_from_conversation_history_request.rs +++ b/paddler/src/agent/continue_from_conversation_history_request.rs @@ -1,5 +1,5 @@ use paddler_types::generated_token_result::GeneratedTokenResult; -use paddler_types::request_params::ContinueFromConversationHistoryParams; +use paddler_types::request_params::continue_from_conversation_history_params::ContinueFromConversationHistoryParams; use paddler_types::request_params::continue_from_conversation_history_params::tool::tool_params::function_call::parameters_schema::validated_parameters_schema::ValidatedParametersSchema; use tokio::sync::mpsc; diff --git a/paddler/src/agent/continuous_batch_active_request.rs b/paddler/src/agent/continuous_batch_active_request.rs index bd561b10..b25292e9 100644 --- a/paddler/src/agent/continuous_batch_active_request.rs +++ b/paddler/src/agent/continuous_batch_active_request.rs @@ -12,7 +12,7 @@ use crate::tool_call_pipeline::ToolCallPipeline; pub struct ContinuousBatchActiveRequest { pub chain: LlamaSampler, - pub token_classifier: SampledTokenClassifier, + pub token_classifier: SampledTokenClassifier<'static>, pub current_token_position: i32, pub grammar_sampler: Option, pub generated_tokens_tx: mpsc::UnboundedSender, @@ -25,7 +25,6 @@ pub struct ContinuousBatchActiveRequest { pub prompt_tokens_ingested: usize, pub sequence_id: i32, pub tool_call_pipeline: Option, - pub utf8_decoder: encoding_rs::Decoder, } impl ContinuousBatchActiveRequest { diff --git a/paddler/src/agent/continuous_batch_scheduler/advance_generating_phase.rs b/paddler/src/agent/continuous_batch_scheduler/advance_generating_phase.rs index ed7b55c5..1de8c613 100644 --- a/paddler/src/agent/continuous_batch_scheduler/advance_generating_phase.rs +++ b/paddler/src/agent/continuous_batch_scheduler/advance_generating_phase.rs @@ -1,3 +1,4 @@ +use llama_cpp_bindings::SampledToken; use llama_cpp_bindings::context::LlamaContext; use log::error; use log::warn; @@ -78,14 +79,15 @@ impl AdvanceGeneratingPhase<'_> { } }; - let classified = ClassifyTokenPhase.run(request, raw_token); + let classified_outcomes = ClassifyTokenPhase.run(request, raw_token); let completion_phase = CompletionCheckPhase { model: &self.scheduler_context.model, }; + let raw_as_sampled = SampledToken::Content(raw_token); if matches!( - completion_phase.run(request, &classified.sampled_token), + completion_phase.run(request, &raw_as_sampled), CompletionCheckOutcome::ReachedEog ) { return Some(AdvanceOutcome::Completed(GeneratedTokenResult::Done( @@ -95,51 +97,50 @@ impl AdvanceGeneratingPhase<'_> { ))); } - let piece = match (EmitTokenPhase { - model: &self.scheduler_context.model, - }) - .run(request, &classified) - { - EmitTokenOutcome::Emitted(piece) => piece, - EmitTokenOutcome::PieceConversionFailed(message) => { - error!( - "{:?}: sequence {} token_to_piece failed: {message}", - self.scheduler_context.agent_name, request.sequence_id - ); - return Some(AdvanceOutcome::Completed( - GeneratedTokenResult::SamplerError(format!( - "Failed to convert token to string: {message}" - )), - )); - } - EmitTokenOutcome::ChannelDropped => { + let emit_phase = EmitTokenPhase; + for classified in &classified_outcomes { + let piece = match emit_phase.run(request, classified) { + EmitTokenOutcome::Emitted(piece) => piece, + EmitTokenOutcome::PieceConversionFailed(message) => { + error!( + "{:?}: sequence {} token_to_piece failed: {message}", + self.scheduler_context.agent_name, request.sequence_id + ); + return Some(AdvanceOutcome::Completed( + GeneratedTokenResult::SamplerError(format!( + "Failed to convert token to string: {message}" + )), + )); + } + EmitTokenOutcome::ChannelDropped => { + warn!( + "{:?}: sequence {} client disconnected (receiver dropped)", + self.scheduler_context.agent_name, request.sequence_id + ); + return Some(AdvanceOutcome::ChannelDropped); + } + }; + + if let Some(event) = + ToolCallPass.run(request.tool_call_pipeline.as_mut(), classified, &piece) + && request.generated_tokens_tx.send(event).is_err() + { warn!( "{:?}: sequence {} client disconnected (receiver dropped)", self.scheduler_context.agent_name, request.sequence_id ); return Some(AdvanceOutcome::ChannelDropped); } - }; - - if let Some(event) = - ToolCallPass.run(request.tool_call_pipeline.as_mut(), &classified, &piece) - && request.generated_tokens_tx.send(event).is_err() - { - warn!( - "{:?}: sequence {} client disconnected (receiver dropped)", - self.scheduler_context.agent_name, request.sequence_id - ); - return Some(AdvanceOutcome::ChannelDropped); } - match completion_phase.run(request, &classified.sampled_token) { + match completion_phase.run(request, &raw_as_sampled) { CompletionCheckOutcome::ReachedEog | CompletionCheckOutcome::ReachedMaxTokens => Some( AdvanceOutcome::Completed(GeneratedTokenResult::Done(GenerationSummary { usage: *request.token_classifier.usage(), })), ), CompletionCheckOutcome::Continue => { - Some(AdvanceOutcome::SampledAndStored(classified.sampled_token)) + Some(AdvanceOutcome::SampledAndStored(raw_as_sampled)) } } } diff --git a/paddler/src/agent/continuous_batch_scheduler/classified_token.rs b/paddler/src/agent/continuous_batch_scheduler/classified_token.rs index 85a282ad..2a9357cf 100644 --- a/paddler/src/agent/continuous_batch_scheduler/classified_token.rs +++ b/paddler/src/agent/continuous_batch_scheduler/classified_token.rs @@ -4,4 +4,8 @@ pub struct ClassifiedToken { pub sampled_token: SampledToken, pub was_in_tool_call: bool, pub is_in_tool_call: bool, + /// User-visible decoded piece. Empty when this token is part of a marker + /// (e.g. `` or `[/THINK]`) — emit phases must skip emission for + /// empty pieces so marker text never reaches client streams. + pub visible_piece: String, } diff --git a/paddler/src/agent/continuous_batch_scheduler/classify_token_phase.rs b/paddler/src/agent/continuous_batch_scheduler/classify_token_phase.rs index da26307b..382c23a4 100644 --- a/paddler/src/agent/continuous_batch_scheduler/classify_token_phase.rs +++ b/paddler/src/agent/continuous_batch_scheduler/classify_token_phase.rs @@ -1,3 +1,5 @@ +use llama_cpp_bindings::SampledToken; +use llama_cpp_bindings::sampled_token_classifier::SampledTokenSection; use llama_cpp_bindings::token::LlamaToken; use crate::agent::continuous_batch_active_request::ContinuousBatchActiveRequest; @@ -10,15 +12,33 @@ impl ClassifyTokenPhase { self, request: &mut ContinuousBatchActiveRequest, raw_token: LlamaToken, - ) -> ClassifiedToken { - let was_in_tool_call = request.token_classifier.is_in_tool_call(); - let sampled_token = request.token_classifier.ingest(raw_token); - let is_in_tool_call = request.token_classifier.is_in_tool_call(); + ) -> Vec { + let section_before_ingest = request.token_classifier.current_section(); + let outcomes = request.token_classifier.ingest(raw_token); - ClassifiedToken { - sampled_token, - was_in_tool_call, - is_in_tool_call, - } + let mut previous_section = section_before_ingest; + outcomes + .into_iter() + .map(|outcome| { + let section = section_of(outcome.sampled_token); + let classified = ClassifiedToken { + sampled_token: outcome.sampled_token, + was_in_tool_call: previous_section == SampledTokenSection::ToolCall, + is_in_tool_call: section == SampledTokenSection::ToolCall, + visible_piece: outcome.visible_piece, + }; + previous_section = section; + classified + }) + .collect() + } +} + +const fn section_of(token: SampledToken) -> SampledTokenSection { + match token { + SampledToken::Reasoning(_) => SampledTokenSection::Reasoning, + SampledToken::Content(_) => SampledTokenSection::Content, + SampledToken::ToolCall(_) => SampledTokenSection::ToolCall, + SampledToken::Undeterminable(_) => SampledTokenSection::Pending, } } diff --git a/paddler/src/agent/continuous_batch_scheduler/emit_token_phase.rs b/paddler/src/agent/continuous_batch_scheduler/emit_token_phase.rs index 2460e9dc..f80e5b71 100644 --- a/paddler/src/agent/continuous_batch_scheduler/emit_token_phase.rs +++ b/paddler/src/agent/continuous_batch_scheduler/emit_token_phase.rs @@ -1,33 +1,23 @@ use llama_cpp_bindings::SampledToken; -use llama_cpp_bindings::model::LlamaModel; use paddler_types::generated_token_result::GeneratedTokenResult; use crate::agent::continuous_batch_active_request::ContinuousBatchActiveRequest; use crate::agent::continuous_batch_scheduler::classified_token::ClassifiedToken; use crate::agent::continuous_batch_scheduler::emit_token_outcome::EmitTokenOutcome; -pub struct EmitTokenPhase<'model> { - pub model: &'model LlamaModel, -} +pub struct EmitTokenPhase; -impl EmitTokenPhase<'_> { +impl EmitTokenPhase { pub fn run( &self, request: &mut ContinuousBatchActiveRequest, classified: &ClassifiedToken, ) -> EmitTokenOutcome { - let piece = match self.model.token_to_piece( - &classified.sampled_token, - &mut request.utf8_decoder, - true, - None, - ) { - Ok(piece) => piece, - Err(err) => { - return EmitTokenOutcome::PieceConversionFailed(err.to_string()); - } - }; + if classified.visible_piece.is_empty() { + return EmitTokenOutcome::Emitted(String::new()); + } + let piece = classified.visible_piece.clone(); let event = match classified.sampled_token { SampledToken::Content(_) => GeneratedTokenResult::ContentToken(piece.clone()), SampledToken::Reasoning(_) => GeneratedTokenResult::ReasoningToken(piece.clone()), diff --git a/paddler/src/agent/continuous_batch_scheduler/mod.rs b/paddler/src/agent/continuous_batch_scheduler/mod.rs index ba52b820..cfcc0718 100644 --- a/paddler/src/agent/continuous_batch_scheduler/mod.rs +++ b/paddler/src/agent/continuous_batch_scheduler/mod.rs @@ -1,3 +1,24 @@ +pub mod advance_generating_phase; +pub mod advance_outcome; +pub mod assemble_batch_phase; +pub mod batch_pass; +pub mod classified_token; +pub mod classify_token_phase; +pub mod commit_phase; +pub mod completion_check_outcome; +pub mod completion_check_phase; +pub mod contributions; +pub mod decode_batch_phase; +pub mod decode_outcome; +pub mod emit_token_outcome; +pub mod emit_token_phase; +pub mod generating_contribution; +pub mod ingesting_contribution; +pub mod sample_outcome; +pub mod sample_token_phase; +pub mod tool_call_pass; +pub mod tool_call_pipeline_build_outcome; + use std::collections::VecDeque; use std::sync::Arc; use std::sync::mpsc::Receiver; @@ -27,6 +48,13 @@ use rand::Rng as _; use rand::rngs::ThreadRng; use tokio::sync::mpsc; +use self::advance_generating_phase::AdvanceGeneratingPhase; +use self::assemble_batch_phase::AssembleBatchPhase; +use self::batch_pass::BatchPass; +use self::commit_phase::CommitPhase; +use self::decode_batch_phase::DecodeBatchPhase; +use self::decode_outcome::DecodeOutcome; +use self::tool_call_pipeline_build_outcome::ToolCallPipelineBuildOutcome; use crate::agent::continue_from_conversation_history_request::ContinueFromConversationHistoryRequest; use crate::agent::continue_from_raw_prompt_request::ContinueFromRawPromptRequest; use crate::agent::continuous_batch_active_request::ContinuousBatchActiveRequest; @@ -42,35 +70,6 @@ use crate::agent::resolve_grammar::resolve_grammar; use crate::agent::sample_token_at_batch_index::sample_token_at_batch_index; use crate::agent::sampling_outcome::SamplingOutcome; use crate::agent::sequence_id_pool::SequenceIdPool; - -pub mod advance_generating_phase; -pub mod advance_outcome; -pub mod assemble_batch_phase; -pub mod batch_pass; -pub mod classified_token; -pub mod classify_token_phase; -pub mod commit_phase; -pub mod completion_check_outcome; -pub mod completion_check_phase; -pub mod contributions; -pub mod decode_batch_phase; -pub mod decode_outcome; -pub mod emit_token_outcome; -pub mod emit_token_phase; -pub mod generating_contribution; -pub mod ingesting_contribution; -pub mod sample_outcome; -pub mod sample_token_phase; -pub mod tool_call_pass; -pub mod tool_call_pipeline_build_outcome; - -use self::advance_generating_phase::AdvanceGeneratingPhase; -use self::assemble_batch_phase::AssembleBatchPhase; -use self::batch_pass::BatchPass; -use self::commit_phase::CommitPhase; -use self::decode_batch_phase::DecodeBatchPhase; -use self::decode_outcome::DecodeOutcome; -use crate::agent::continuous_batch_scheduler::tool_call_pipeline_build_outcome::ToolCallPipelineBuildOutcome; use crate::decoded_image::DecodedImage; use crate::dispenses_slots::DispensesSlots; use crate::slot_aggregated_status::SlotAggregatedStatus; @@ -372,6 +371,23 @@ impl ContinuousBatchScheduler { ) } + #[expect( + unsafe_code, + reason = "the SchedulerContext owns the LlamaModel for the lifetime of the active_requests vec — same pattern as LlamaContext<'static> above" + )] + fn build_token_classifier_for_active_request( + &self, + ) -> llama_cpp_bindings::SampledTokenClassifier<'static> { + let classifier = self.scheduler_context.model.sampled_token_classifier(); + + unsafe { + std::mem::transmute::< + llama_cpp_bindings::SampledTokenClassifier<'_>, + llama_cpp_bindings::SampledTokenClassifier<'static>, + >(classifier) + } + } + fn build_tool_call_pipeline( &self, tools: Vec>, @@ -515,32 +531,10 @@ impl ContinuousBatchScheduler { let chain = self.create_sampler_chain(); - let mut token_classifier = match self.scheduler_context.model.sampled_token_classifier() { - Ok(token_classifier) => token_classifier, - Err(err) => { - let message = format!( - "{:?}: failed to build reasoning token classifier: {err}", - self.scheduler_context.agent_name - ); - - error!("{message}"); - self.sequence_id_pool.release(sequence_id); - - if generated_tokens_tx - .send(GeneratedTokenResult::SamplerError(message)) - .is_err() - { - warn!( - "{:?}: failed to send result to client (receiver dropped)", - self.scheduler_context.agent_name - ); - } - - return Ok(()); - } - }; + let mut token_classifier = self.build_token_classifier_for_active_request(); token_classifier.record_prompt_tokens(prompt_tokens.len() as u64); + token_classifier.ingest_prompt_tokens(&prompt_tokens); #[expect( clippy::cast_sign_loss, @@ -579,7 +573,6 @@ impl ContinuousBatchScheduler { prompt_tokens_ingested: 0, sequence_id, tool_call_pipeline, - utf8_decoder: encoding_rs::UTF_8.new_decoder(), }); Ok(()) @@ -734,30 +727,7 @@ impl ContinuousBatchScheduler { self.harvest_pending_samples_before_external_decode(); - let mut token_classifier = match self.scheduler_context.model.sampled_token_classifier() { - Ok(token_classifier) => token_classifier, - Err(err) => { - let message = format!( - "{:?}: failed to build reasoning token classifier: {err}", - self.scheduler_context.agent_name - ); - - error!("{message}"); - self.sequence_id_pool.release(sequence_id); - - if generated_tokens_tx - .send(GeneratedTokenResult::SamplerError(message)) - .is_err() - { - warn!( - "{:?}: failed to send result to client (receiver dropped)", - self.scheduler_context.agent_name - ); - } - - return Ok(()); - } - }; + let mut token_classifier = self.build_token_classifier_for_active_request(); #[expect( clippy::cast_possible_truncation, @@ -834,7 +804,6 @@ impl ContinuousBatchScheduler { prompt_tokens_ingested: 0, sequence_id, tool_call_pipeline, - utf8_decoder: encoding_rs::UTF_8.new_decoder(), }); Ok(()) @@ -864,8 +833,14 @@ impl ContinuousBatchScheduler { &mut active_request.grammar_sampler, ) { Ok(SamplingOutcome::Token(raw_token)) => { + // Update classifier state (section / usage counters) but drop the + // outcomes — harvest-sampled tokens are funnelled into the next + // batch via `pending_sampled_token`; their user-visible emission + // happens in `advance_generating_phase` after the next decode, + // not here. + let _ = active_request.token_classifier.ingest(raw_token); active_request.pending_sampled_token = - Some(active_request.token_classifier.ingest(raw_token)); + Some(llama_cpp_bindings::SampledToken::Content(raw_token)); active_request.i_batch = None; } Ok(SamplingOutcome::AllCandidatesEliminated) => { diff --git a/paddler/src/agent/continuous_batch_scheduler/tool_call_pass.rs b/paddler/src/agent/continuous_batch_scheduler/tool_call_pass.rs index 04be416e..5f11e13a 100644 --- a/paddler/src/agent/continuous_batch_scheduler/tool_call_pass.rs +++ b/paddler/src/agent/continuous_batch_scheduler/tool_call_pass.rs @@ -53,6 +53,7 @@ mod tests { sampled_token: sampled, was_in_tool_call: was, is_in_tool_call: is, + visible_piece: String::new(), } } diff --git a/paddler/src/agent/jsonrpc/notification_params/set_state_params.rs b/paddler/src/agent/jsonrpc/notification_params/set_state_params.rs index 00060ea4..99876259 100644 --- a/paddler/src/agent/jsonrpc/notification_params/set_state_params.rs +++ b/paddler/src/agent/jsonrpc/notification_params/set_state_params.rs @@ -1,8 +1,7 @@ +use paddler_types::agent_desired_state::AgentDesiredState; use serde::Deserialize; use serde::Serialize; -use crate::agent_desired_state::AgentDesiredState; - #[derive(Clone, Debug, Deserialize, Serialize)] #[serde(deny_unknown_fields)] pub struct SetStateParams { diff --git a/paddler/src/agent/jsonrpc/request.rs b/paddler/src/agent/jsonrpc/request.rs index 72243fb8..88d95652 100644 --- a/paddler/src/agent/jsonrpc/request.rs +++ b/paddler/src/agent/jsonrpc/request.rs @@ -1,9 +1,9 @@ use serde::Deserialize; use serde::Serialize; -use paddler_types::request_params::ContinueFromConversationHistoryParams; use paddler_types::request_params::ContinueFromRawPromptParams; use paddler_types::request_params::GenerateEmbeddingBatchParams; +use paddler_types::request_params::continue_from_conversation_history_params::ContinueFromConversationHistoryParams; use paddler_types::request_params::continue_from_conversation_history_params::tool::tool_params::function_call::parameters_schema::validated_parameters_schema::ValidatedParametersSchema; #[derive(Deserialize, Serialize)] diff --git a/paddler/src/agent/management_socket_client_service.rs b/paddler/src/agent/management_socket_client_service.rs index 49762e90..9f32e0bb 100644 --- a/paddler/src/agent/management_socket_client_service.rs +++ b/paddler/src/agent/management_socket_client_service.rs @@ -19,6 +19,7 @@ use tokio_tungstenite::connect_async; use tokio_tungstenite::tungstenite::protocol::Message; use tokio_util::sync::CancellationToken; +use paddler_types::agent_desired_state::AgentDesiredState; use paddler_types::jsonrpc::Error as JsonRpcError; use paddler_types::jsonrpc::ErrorEnvelope; use paddler_types::jsonrpc::RequestEnvelope; @@ -36,7 +37,6 @@ use crate::agent::jsonrpc::notification_params::VersionParams; use crate::agent::model_metadata_holder::ModelMetadataHolder; use crate::agent::receive_stream_stopper_collection::ReceiveStreamStopperCollection; use crate::agent_applicable_state_holder::AgentApplicableStateHolder; -use crate::agent_desired_state::AgentDesiredState; use crate::balancer::management_service::http_route::api::ws_agent_socket::jsonrpc::Message as ManagementJsonRpcMessage; use crate::balancer::management_service::http_route::api::ws_agent_socket::jsonrpc::Notification as ManagementJsonRpcNotification; use crate::balancer::management_service::http_route::api::ws_agent_socket::jsonrpc::notification_params::RegisterAgentParams; diff --git a/paddler/src/agent/prepare_conversation_history_request.rs b/paddler/src/agent/prepare_conversation_history_request.rs index 2e44426c..87be81d9 100644 --- a/paddler/src/agent/prepare_conversation_history_request.rs +++ b/paddler/src/agent/prepare_conversation_history_request.rs @@ -6,7 +6,7 @@ use log::warn; use minijinja::context; use paddler_types::generated_token_result::GeneratedTokenResult; use paddler_types::media_marker::MediaMarker; -use paddler_types::request_params::ContinueFromConversationHistoryParams; +use paddler_types::request_params::continue_from_conversation_history_params::ContinueFromConversationHistoryParams; use paddler_types::request_params::continue_from_conversation_history_params::tool::tool_params::function_call::parameters_schema::validated_parameters_schema::ValidatedParametersSchema; use tokio::sync::mpsc; diff --git a/paddler/src/agent/reconciliation_service.rs b/paddler/src/agent/reconciliation_service.rs index 08980c6f..8ca79946 100644 --- a/paddler/src/agent/reconciliation_service.rs +++ b/paddler/src/agent/reconciliation_service.rs @@ -3,6 +3,7 @@ use std::sync::Arc; use anyhow::Result; use async_trait::async_trait; use log::error; +use paddler_types::agent_desired_state::AgentDesiredState; use tokio::sync::mpsc; use tokio::time::Duration; use tokio::time::MissedTickBehavior; @@ -10,7 +11,6 @@ use tokio::time::interval; use tokio_util::sync::CancellationToken; use crate::agent_applicable_state_holder::AgentApplicableStateHolder; -use crate::agent_desired_state::AgentDesiredState; use crate::agent_issue_fix::AgentIssueFix; use crate::converts_to_applicable_state::ConvertsToApplicableState as _; use crate::service::Service; diff --git a/paddler/src/agent_desired_state.rs b/paddler/src/agent_desired_state.rs index a9ef0bc2..541375ea 100644 --- a/paddler/src/agent_desired_state.rs +++ b/paddler/src/agent_desired_state.rs @@ -2,7 +2,7 @@ use std::sync::Arc; use anyhow::Result; use async_trait::async_trait; -pub use paddler_types::agent_desired_state::AgentDesiredState; +use paddler_types::agent_desired_state::AgentDesiredState; use crate::agent_applicable_state::AgentApplicableState; use crate::converts_to_applicable_state::ConvertsToApplicableState; diff --git a/paddler/src/balancer/agent_controller.rs b/paddler/src/balancer/agent_controller.rs index 11c7cb3b..c37c2b15 100644 --- a/paddler/src/balancer/agent_controller.rs +++ b/paddler/src/balancer/agent_controller.rs @@ -13,11 +13,12 @@ use tokio::sync::mpsc; use tokio_util::sync::CancellationToken; use paddler_types::agent_controller_snapshot::AgentControllerSnapshot; +use paddler_types::agent_desired_state::AgentDesiredState; use paddler_types::agent_issue::AgentIssue; use paddler_types::jsonrpc::RequestEnvelope; -use paddler_types::request_params::ContinueFromConversationHistoryParams; use paddler_types::request_params::ContinueFromRawPromptParams; use paddler_types::request_params::GenerateEmbeddingBatchParams; +use paddler_types::request_params::continue_from_conversation_history_params::ContinueFromConversationHistoryParams; use paddler_types::request_params::continue_from_conversation_history_params::tool::tool_params::function_call::parameters_schema::validated_parameters_schema::ValidatedParametersSchema; use paddler_types::slot_aggregated_status_snapshot::SlotAggregatedStatusSnapshot; @@ -25,7 +26,6 @@ use crate::agent::jsonrpc::Message as AgentJsonRpcMessage; use crate::agent::jsonrpc::Notification as AgentJsonRpcNotification; use crate::agent::jsonrpc::Request as AgentJsonRpcRequest; use crate::agent::jsonrpc::notification_params::SetStateParams; -use crate::agent_desired_state::AgentDesiredState; use crate::atomic_value::AtomicValue; use crate::balancer::agent_controller_update_result::AgentControllerUpdateResult; use crate::balancer::chat_template_override_sender_collection::ChatTemplateOverrideSenderCollection; diff --git a/paddler/src/balancer/agent_controller_pool.rs b/paddler/src/balancer/agent_controller_pool.rs index 1e74e088..1c47a478 100644 --- a/paddler/src/balancer/agent_controller_pool.rs +++ b/paddler/src/balancer/agent_controller_pool.rs @@ -5,11 +5,11 @@ use async_trait::async_trait; use dashmap::DashMap; use paddler_types::agent_controller_pool_snapshot::AgentControllerPoolSnapshot; use paddler_types::agent_controller_snapshot::AgentControllerSnapshot; +use paddler_types::agent_desired_state::AgentDesiredState; use tokio::sync::watch; use super::agent_controller::AgentController; use super::agent_controller_pool_total_slots::AgentControllerPoolTotalSlots; -use crate::agent_desired_state::AgentDesiredState; use crate::balancer::agent_controller_slot_guard::AgentControllerSlotGuard; use crate::balancer::dispatched_agent::DispatchedAgent; use crate::produces_snapshot::ProducesSnapshot; diff --git a/paddler/src/balancer/compatibility/openai_service/http_route/post_chat_completions.rs b/paddler/src/balancer/compatibility/openai_service/http_route/post_chat_completions.rs index dc4b3f9e..c4a2b08a 100644 --- a/paddler/src/balancer/compatibility/openai_service/http_route/post_chat_completions.rs +++ b/paddler/src/balancer/compatibility/openai_service/http_route/post_chat_completions.rs @@ -20,12 +20,12 @@ use paddler_types::inference_client::Message as OutgoingMessage; use paddler_types::inference_client::Response as OutgoingResponse; use paddler_types::jsonrpc::ErrorEnvelope; use paddler_types::jsonrpc::ResponseEnvelope; -use paddler_types::parsed_tool_call::ParsedToolCall; -use paddler_types::parsed_tool_call::ToolCallArguments; -use paddler_types::request_params::ContinueFromConversationHistoryParams; +use llama_cpp_bindings::ParsedToolCall; +use llama_cpp_bindings::TokenUsage; +use llama_cpp_bindings::ToolCallArguments; +use paddler_types::request_params::continue_from_conversation_history_params::ContinueFromConversationHistoryParams; use paddler_types::request_params::continue_from_conversation_history_params::tool::Tool; use paddler_types::request_params::continue_from_conversation_history_params::tool::tool_params::function_call::parameters_schema::raw_parameters_schema::RawParametersSchema; -use paddler_types::token_usage::TokenUsage; use paddler_types::validates::Validates; use serde::Deserialize; use serde_json::json; @@ -710,12 +710,12 @@ mod tests { use paddler_types::generated_token_result::GeneratedTokenResult; use paddler_types::generation_summary::GenerationSummary; use paddler_types::inference_client::Message as OutgoingMessage; + use llama_cpp_bindings::ParsedToolCall; + use llama_cpp_bindings::TokenUsage; + use llama_cpp_bindings::ToolCallArguments; use paddler_types::inference_client::Response as OutgoingResponse; use paddler_types::jsonrpc::ErrorEnvelope; use paddler_types::jsonrpc::ResponseEnvelope; - use paddler_types::parsed_tool_call::ParsedToolCall; - use paddler_types::parsed_tool_call::ToolCallArguments; - use paddler_types::token_usage::TokenUsage; use super::OpenAINonStreamingResponseTransformer; use super::OpenAINonStreamingState; diff --git a/paddler/src/balancer/inference_service/http_route/api/post_continue_from_conversation_history.rs b/paddler/src/balancer/inference_service/http_route/api/post_continue_from_conversation_history.rs index cea36b27..ffe33394 100644 --- a/paddler/src/balancer/inference_service/http_route/api/post_continue_from_conversation_history.rs +++ b/paddler/src/balancer/inference_service/http_route/api/post_continue_from_conversation_history.rs @@ -4,7 +4,7 @@ use actix_web::error::ErrorBadRequest; use actix_web::post; use actix_web::web; -use paddler_types::request_params::ContinueFromConversationHistoryParams; +use paddler_types::request_params::continue_from_conversation_history_params::ContinueFromConversationHistoryParams; use paddler_types::request_params::continue_from_conversation_history_params::tool::tool_params::function_call::parameters_schema::raw_parameters_schema::RawParametersSchema; use paddler_types::validates::Validates as _; diff --git a/paddler/src/balancer_applicable_state.rs b/paddler/src/balancer_applicable_state.rs index 91961b44..eafc71ac 100644 --- a/paddler/src/balancer_applicable_state.rs +++ b/paddler/src/balancer_applicable_state.rs @@ -1,4 +1,4 @@ -use crate::agent_desired_state::AgentDesiredState; +use paddler_types::agent_desired_state::AgentDesiredState; #[derive(Clone, Debug)] pub struct BalancerApplicableState { diff --git a/paddler/src/balancer_applicable_state_holder.rs b/paddler/src/balancer_applicable_state_holder.rs index b5a6407a..94902204 100644 --- a/paddler/src/balancer_applicable_state_holder.rs +++ b/paddler/src/balancer_applicable_state_holder.rs @@ -1,8 +1,8 @@ use std::sync::RwLock; +use paddler_types::agent_desired_state::AgentDesiredState; use tokio::sync::watch; -use crate::agent_desired_state::AgentDesiredState; use crate::balancer_applicable_state::BalancerApplicableState; use crate::subscribes_to_updates::SubscribesToUpdates; diff --git a/paddler/src/lib.rs b/paddler/src/lib.rs index 6c6968ea..245cb2e6 100644 --- a/paddler/src/lib.rs +++ b/paddler/src/lib.rs @@ -1,5 +1,3 @@ -pub use llama_cpp_bindings; - pub mod agent; pub mod agent_applicable_state; pub mod agent_applicable_state_holder; diff --git a/paddler/src/sets_desired_state.rs b/paddler/src/sets_desired_state.rs index 9e78aa5d..8d182a39 100644 --- a/paddler/src/sets_desired_state.rs +++ b/paddler/src/sets_desired_state.rs @@ -1,7 +1,6 @@ use anyhow::Result; use async_trait::async_trait; - -use crate::agent_desired_state::AgentDesiredState; +use paddler_types::agent_desired_state::AgentDesiredState; #[async_trait] pub trait SetsDesiredState { diff --git a/paddler/src/tool_call_event.rs b/paddler/src/tool_call_event.rs index 53af576a..d8ca6b2a 100644 --- a/paddler/src/tool_call_event.rs +++ b/paddler/src/tool_call_event.rs @@ -1,4 +1,4 @@ -use paddler_types::parsed_tool_call::ParsedToolCall; +use llama_cpp_bindings::ParsedToolCall; use crate::tool_call_parse_error::ToolCallParseError; use crate::tool_call_validation_error::ToolCallValidationError; @@ -30,7 +30,7 @@ impl ToolCallEvent { #[cfg(test)] mod tests { - use paddler_types::parsed_tool_call::ParsedToolCall; + use llama_cpp_bindings::ParsedToolCall; use super::ToolCallEvent; use crate::tool_call_parse_error::ToolCallParseError; diff --git a/paddler/src/tool_call_pipeline.rs b/paddler/src/tool_call_pipeline.rs index ca54a8cd..f22a75ef 100644 --- a/paddler/src/tool_call_pipeline.rs +++ b/paddler/src/tool_call_pipeline.rs @@ -1,4 +1,4 @@ -use paddler_types::parsed_tool_call::ParsedToolCall; +use llama_cpp_bindings::ParsedToolCall; use crate::tool_call_buffer::ToolCallBuffer; use crate::tool_call_event::ToolCallEvent; diff --git a/paddler/src/tool_call_validator.rs b/paddler/src/tool_call_validator.rs index 4aa1cf49..63f48a80 100644 --- a/paddler/src/tool_call_validator.rs +++ b/paddler/src/tool_call_validator.rs @@ -1,8 +1,8 @@ use std::collections::HashMap; use jsonschema::Validator; -use paddler_types::parsed_tool_call::ParsedToolCall; -use paddler_types::parsed_tool_call::ToolCallArguments; +use llama_cpp_bindings::ParsedToolCall; +use llama_cpp_bindings::ToolCallArguments; use paddler_types::request_params::continue_from_conversation_history_params::tool::Tool; use paddler_types::request_params::continue_from_conversation_history_params::tool::tool_params::function_call::parameters::Parameters; use paddler_types::request_params::continue_from_conversation_history_params::tool::tool_params::function_call::parameters_schema::validated_parameters_schema::ValidatedParametersSchema; @@ -102,10 +102,10 @@ impl ToolCallValidator { mod tests { use anyhow::Result; use anyhow::bail; - use paddler_types::parsed_tool_call::ParsedToolCall; - use paddler_types::parsed_tool_call::ToolCallArguments; + use llama_cpp_bindings::ParsedToolCall; + use llama_cpp_bindings::ToolCallArguments; use paddler_types::request_params::continue_from_conversation_history_params::tool::Tool; - use paddler_types::request_params::continue_from_conversation_history_params::tool::tool_params::FunctionCall; + use paddler_types::request_params::continue_from_conversation_history_params::tool::tool_params::function_call::FunctionCall; use paddler_types::request_params::continue_from_conversation_history_params::tool::tool_params::function_call::function::Function; use paddler_types::request_params::continue_from_conversation_history_params::tool::tool_params::function_call::parameters::Parameters; use paddler_types::request_params::continue_from_conversation_history_params::tool::tool_params::function_call::parameters_schema::validated_parameters_schema::ValidatedParametersSchema; diff --git a/paddler_bootstrap/src/bootstrapped_agent_handle.rs b/paddler_bootstrap/src/bootstrapped_agent_handle.rs index 3b2408f0..9d87fdb5 100644 --- a/paddler_bootstrap/src/bootstrapped_agent_handle.rs +++ b/paddler_bootstrap/src/bootstrapped_agent_handle.rs @@ -9,10 +9,10 @@ use paddler::agent::management_socket_client_service::ManagementSocketClientServ use paddler::agent::model_metadata_holder::ModelMetadataHolder; use paddler::agent::reconciliation_service::ReconciliationService; use paddler::agent_applicable_state_holder::AgentApplicableStateHolder; -use paddler::agent_desired_state::AgentDesiredState; use paddler::service_manager::ServiceManager; use paddler::slot_aggregated_status::SlotAggregatedStatus; use paddler::slot_aggregated_status_manager::SlotAggregatedStatusManager; +use paddler_types::agent_desired_state::AgentDesiredState; use tokio::sync::mpsc; pub struct BootstrappedAgentHandle { diff --git a/paddler_cli/src/main.rs b/paddler_cli/src/main.rs index b406ea23..2bf0ad5c 100644 --- a/paddler_cli/src/main.rs +++ b/paddler_cli/src/main.rs @@ -1,13 +1,13 @@ +mod cmd; + use anyhow::Result; use clap::Parser; use clap::Subcommand; -#[cfg(feature = "web_admin_panel")] -use esbuild_metafile::instance::initialize_instance; -mod cmd; - use cmd::agent::Agent; use cmd::balancer::Balancer; use cmd::handler::Handler as _; +#[cfg(feature = "web_admin_panel")] +use esbuild_metafile::instance::initialize_instance; use paddler_bootstrap::shutdown_signal::wait_for_shutdown_signal; use tokio_util::sync::CancellationToken; diff --git a/paddler_client/src/client_inference.rs b/paddler_client/src/client_inference.rs index 9b84aea1..86fe6ddf 100644 --- a/paddler_client/src/client_inference.rs +++ b/paddler_client/src/client_inference.rs @@ -5,9 +5,9 @@ use paddler_types::inference_client::Message as InferenceMessage; use paddler_types::inference_server::Message as InferenceServerMessage; use paddler_types::inference_server::Request as InferenceServerRequest; use paddler_types::jsonrpc::RequestEnvelope; -use paddler_types::request_params::ContinueFromConversationHistoryParams; use paddler_types::request_params::ContinueFromRawPromptParams; use paddler_types::request_params::GenerateEmbeddingBatchParams; +use paddler_types::request_params::continue_from_conversation_history_params::ContinueFromConversationHistoryParams; use paddler_types::request_params::continue_from_conversation_history_params::tool::tool_params::function_call::parameters_schema::validated_parameters_schema::ValidatedParametersSchema; use reqwest::Client; use tokio_stream::wrappers::UnboundedReceiverStream; diff --git a/paddler_client/src/lib.rs b/paddler_client/src/lib.rs index 2c805849..0939d664 100644 --- a/paddler_client/src/lib.rs +++ b/paddler_client/src/lib.rs @@ -1,10 +1,10 @@ -pub mod agents_stream; -pub mod buffered_requests_stream; -pub mod client_inference; -pub mod client_management; -pub mod error; +mod agents_stream; +mod buffered_requests_stream; +mod client_inference; +mod client_management; +mod error; mod format_api_url; -pub mod inference_message_stream; +mod inference_message_stream; mod inference_socket; mod stream; diff --git a/paddler_gui/src/running_balancer_snapshot.rs b/paddler_gui/src/running_balancer_snapshot.rs index 5782ff99..dcf57484 100644 --- a/paddler_gui/src/running_balancer_snapshot.rs +++ b/paddler_gui/src/running_balancer_snapshot.rs @@ -53,7 +53,6 @@ mod tests { use std::sync::atomic::AtomicUsize; use anyhow::Result; - use paddler::agent_desired_state::AgentDesiredState; use paddler::atomic_value::AtomicValue; use paddler::balancer::agent_controller::AgentController; use paddler::balancer::agent_controller_pool::AgentControllerPool; @@ -64,6 +63,7 @@ mod tests { use paddler::balancer_applicable_state::BalancerApplicableState; use paddler::balancer_applicable_state_holder::BalancerApplicableStateHolder; use paddler_types::agent_desired_model::AgentDesiredModel; + use paddler_types::agent_desired_state::AgentDesiredState; use paddler_types::agent_state_application_status::AgentStateApplicationStatus; use paddler_types::inference_parameters::InferenceParameters; use tokio::sync::mpsc; diff --git a/paddler_tests/src/inference_http_client.rs b/paddler_tests/src/inference_http_client.rs index cf20bee3..a17436c6 100644 --- a/paddler_tests/src/inference_http_client.rs +++ b/paddler_tests/src/inference_http_client.rs @@ -4,9 +4,9 @@ use async_stream::try_stream; use futures_util::Stream; use futures_util::StreamExt as _; use paddler_types::inference_client::Message as InferenceMessage; -use paddler_types::request_params::ContinueFromConversationHistoryParams; use paddler_types::request_params::ContinueFromRawPromptParams; use paddler_types::request_params::GenerateEmbeddingBatchParams; +use paddler_types::request_params::continue_from_conversation_history_params::ContinueFromConversationHistoryParams; use paddler_types::request_params::continue_from_conversation_history_params::tool::tool_params::function_call::parameters_schema::validated_parameters_schema::ValidatedParametersSchema; use reqwest::Client; use url::Url; diff --git a/paddler_tests/src/lib.rs b/paddler_tests/src/lib.rs index 475a496a..3f4c32e2 100644 --- a/paddler_tests/src/lib.rs +++ b/paddler_tests/src/lib.rs @@ -23,9 +23,12 @@ pub mod parse_test_device_value; pub mod spawn_agent_subprocess; pub mod spawn_agent_subprocess_params; pub mod start_in_process_cluster; +pub mod start_in_process_cluster_with_gemma_4; +pub mod start_in_process_cluster_with_ministral_3; pub mod start_in_process_cluster_with_qwen2_5_vl; pub mod start_in_process_cluster_with_qwen3; pub mod start_in_process_cluster_with_qwen3_5; +pub mod start_in_process_cluster_with_qwen3_6; pub mod start_in_process_cluster_with_smolvlm2; pub mod start_in_process_embedding_cluster; pub mod start_subprocess_cluster; diff --git a/paddler_tests/src/model_card/gemma_4_e4b_it.rs b/paddler_tests/src/model_card/gemma_4_e4b_it.rs new file mode 100644 index 00000000..2959b2cc --- /dev/null +++ b/paddler_tests/src/model_card/gemma_4_e4b_it.rs @@ -0,0 +1,15 @@ +use paddler_types::huggingface_model_reference::HuggingFaceModelReference; + +use crate::model_card::ModelCard; + +#[must_use] +pub fn gemma_4_e4b_it() -> ModelCard { + ModelCard { + gpu_layer_count: 999, + reference: HuggingFaceModelReference { + filename: "gemma-4-E4B-it-Q4_K_M.gguf".to_owned(), + repo_id: "unsloth/gemma-4-E4B-it-GGUF".to_owned(), + revision: "main".to_owned(), + }, + } +} diff --git a/paddler_tests/src/model_card/ministral_3_14b_reasoning.rs b/paddler_tests/src/model_card/ministral_3_14b_reasoning.rs new file mode 100644 index 00000000..0718b7fa --- /dev/null +++ b/paddler_tests/src/model_card/ministral_3_14b_reasoning.rs @@ -0,0 +1,15 @@ +use paddler_types::huggingface_model_reference::HuggingFaceModelReference; + +use crate::model_card::ModelCard; + +#[must_use] +pub fn ministral_3_14b_reasoning() -> ModelCard { + ModelCard { + gpu_layer_count: 999, + reference: HuggingFaceModelReference { + filename: "Ministral-3-14B-Reasoning-2512-Q4_K_M.gguf".to_owned(), + repo_id: "unsloth/Ministral-3-14B-Reasoning-2512-GGUF".to_owned(), + revision: "main".to_owned(), + }, + } +} diff --git a/paddler_tests/src/model_card/mod.rs b/paddler_tests/src/model_card/mod.rs index d064e778..fdc22761 100644 --- a/paddler_tests/src/model_card/mod.rs +++ b/paddler_tests/src/model_card/mod.rs @@ -1,15 +1,18 @@ -use paddler_types::huggingface_model_reference::HuggingFaceModelReference; - +pub mod gemma_4_e4b_it; +pub mod ministral_3_14b_reasoning; pub mod nomic_embed_text_v1_5; pub mod qwen2_5_vl_3b; pub mod qwen2_5_vl_3b_mmproj; pub mod qwen3_0_6b; pub mod qwen3_5_0_8b; pub mod qwen3_5_0_8b_mmproj; +pub mod qwen3_6_35b_a3b; pub mod qwen3_embedding_0_6b; pub mod smolvlm2_256m; pub mod smolvlm2_256m_mmproj; +use paddler_types::huggingface_model_reference::HuggingFaceModelReference; + pub struct ModelCard { pub gpu_layer_count: u32, pub reference: HuggingFaceModelReference, diff --git a/paddler_tests/src/model_card/qwen3_6_35b_a3b.rs b/paddler_tests/src/model_card/qwen3_6_35b_a3b.rs new file mode 100644 index 00000000..c75a5f8a --- /dev/null +++ b/paddler_tests/src/model_card/qwen3_6_35b_a3b.rs @@ -0,0 +1,15 @@ +use paddler_types::huggingface_model_reference::HuggingFaceModelReference; + +use crate::model_card::ModelCard; + +#[must_use] +pub fn qwen3_6_35b_a3b() -> ModelCard { + ModelCard { + gpu_layer_count: 999, + reference: HuggingFaceModelReference { + filename: "Qwen3.6-35B-A3B-UD-Q4_K_M.gguf".to_owned(), + repo_id: "unsloth/Qwen3.6-35B-A3B-GGUF".to_owned(), + revision: "main".to_owned(), + }, + } +} diff --git a/paddler_tests/src/start_in_process_cluster_with_gemma_4.rs b/paddler_tests/src/start_in_process_cluster_with_gemma_4.rs new file mode 100644 index 00000000..1b24cc44 --- /dev/null +++ b/paddler_tests/src/start_in_process_cluster_with_gemma_4.rs @@ -0,0 +1,35 @@ +use anyhow::Result; +use paddler_types::agent_desired_model::AgentDesiredModel; +use paddler_types::balancer_desired_state::BalancerDesiredState; + +use crate::cluster_handle::ClusterHandle; +use crate::current_test_device::current_test_device; +use crate::in_process_cluster_params::InProcessClusterParams; +use crate::model_card::ModelCard; +use crate::model_card::gemma_4_e4b_it::gemma_4_e4b_it; +use crate::start_in_process_cluster::start_in_process_cluster; + +pub async fn start_in_process_cluster_with_gemma_4(slots_per_agent: i32) -> Result { + let device = current_test_device()?; + + device.require_available()?; + + let ModelCard { + gpu_layer_count, + reference, + } = gemma_4_e4b_it(); + + start_in_process_cluster(InProcessClusterParams { + slots_per_agent, + desired_state: BalancerDesiredState { + chat_template_override: None, + inference_parameters: device.inference_parameters_for_full_offload(gpu_layer_count), + model: AgentDesiredModel::HuggingFace(reference), + multimodal_projection: AgentDesiredModel::None, + use_chat_template_override: false, + }, + wait_for_slots_ready: true, + ..InProcessClusterParams::default() + }) + .await +} diff --git a/paddler_tests/src/start_in_process_cluster_with_ministral_3.rs b/paddler_tests/src/start_in_process_cluster_with_ministral_3.rs new file mode 100644 index 00000000..7a2eea0b --- /dev/null +++ b/paddler_tests/src/start_in_process_cluster_with_ministral_3.rs @@ -0,0 +1,37 @@ +use anyhow::Result; +use paddler_types::agent_desired_model::AgentDesiredModel; +use paddler_types::balancer_desired_state::BalancerDesiredState; + +use crate::cluster_handle::ClusterHandle; +use crate::current_test_device::current_test_device; +use crate::in_process_cluster_params::InProcessClusterParams; +use crate::model_card::ModelCard; +use crate::model_card::ministral_3_14b_reasoning::ministral_3_14b_reasoning; +use crate::start_in_process_cluster::start_in_process_cluster; + +pub async fn start_in_process_cluster_with_ministral_3( + slots_per_agent: i32, +) -> Result { + let device = current_test_device()?; + + device.require_available()?; + + let ModelCard { + gpu_layer_count, + reference, + } = ministral_3_14b_reasoning(); + + start_in_process_cluster(InProcessClusterParams { + slots_per_agent, + desired_state: BalancerDesiredState { + chat_template_override: None, + inference_parameters: device.inference_parameters_for_full_offload(gpu_layer_count), + model: AgentDesiredModel::HuggingFace(reference), + multimodal_projection: AgentDesiredModel::None, + use_chat_template_override: false, + }, + wait_for_slots_ready: true, + ..InProcessClusterParams::default() + }) + .await +} diff --git a/paddler_tests/src/start_in_process_cluster_with_qwen3_6.rs b/paddler_tests/src/start_in_process_cluster_with_qwen3_6.rs new file mode 100644 index 00000000..560d9c83 --- /dev/null +++ b/paddler_tests/src/start_in_process_cluster_with_qwen3_6.rs @@ -0,0 +1,35 @@ +use anyhow::Result; +use paddler_types::agent_desired_model::AgentDesiredModel; +use paddler_types::balancer_desired_state::BalancerDesiredState; + +use crate::cluster_handle::ClusterHandle; +use crate::current_test_device::current_test_device; +use crate::in_process_cluster_params::InProcessClusterParams; +use crate::model_card::ModelCard; +use crate::model_card::qwen3_6_35b_a3b::qwen3_6_35b_a3b; +use crate::start_in_process_cluster::start_in_process_cluster; + +pub async fn start_in_process_cluster_with_qwen3_6(slots_per_agent: i32) -> Result { + let device = current_test_device()?; + + device.require_available()?; + + let ModelCard { + gpu_layer_count, + reference, + } = qwen3_6_35b_a3b(); + + start_in_process_cluster(InProcessClusterParams { + slots_per_agent, + desired_state: BalancerDesiredState { + chat_template_override: None, + inference_parameters: device.inference_parameters_for_full_offload(gpu_layer_count), + model: AgentDesiredModel::HuggingFace(reference), + multimodal_projection: AgentDesiredModel::None, + use_chat_template_override: false, + }, + wait_for_slots_ready: true, + ..InProcessClusterParams::default() + }) + .await +} diff --git a/paddler_tests/src/test_device.rs b/paddler_tests/src/test_device.rs index 569b42e2..4468ffae 100644 --- a/paddler_tests/src/test_device.rs +++ b/paddler_tests/src/test_device.rs @@ -2,11 +2,11 @@ use anyhow::Result; #[cfg(any(feature = "cuda", feature = "metal"))] use anyhow::bail; #[cfg(any(feature = "cuda", feature = "metal"))] -use paddler::llama_cpp_bindings::llama_backend::LlamaBackend; +use llama_cpp_bindings::llama_backend::LlamaBackend; #[cfg(any(feature = "cuda", feature = "metal"))] -use paddler::llama_cpp_bindings::llama_backend_device::LlamaBackendDeviceType; +use llama_cpp_bindings::llama_backend_device::LlamaBackendDeviceType; #[cfg(any(feature = "cuda", feature = "metal"))] -use paddler::llama_cpp_bindings::llama_backend_device::list_llama_ggml_backend_devices; +use llama_cpp_bindings::llama_backend_device::list_llama_ggml_backend_devices; use paddler_types::inference_parameters::InferenceParameters; #[derive(Clone, Copy, Debug, Eq, PartialEq)] diff --git a/paddler_tests/tests/agent_conversation_with_function_tool_succeeds.rs b/paddler_tests/tests/agent_conversation_with_function_tool_succeeds.rs index 270867e6..7bd50fb1 100644 --- a/paddler_tests/tests/agent_conversation_with_function_tool_succeeds.rs +++ b/paddler_tests/tests/agent_conversation_with_function_tool_succeeds.rs @@ -12,7 +12,7 @@ use paddler_types::conversation_message::ConversationMessage; use paddler_types::conversation_message_content::ConversationMessageContent; use paddler_types::request_params::continue_from_conversation_history_params::ContinueFromConversationHistoryParams; use paddler_types::request_params::continue_from_conversation_history_params::tool::Tool; -use paddler_types::request_params::continue_from_conversation_history_params::tool::tool_params::FunctionCall; +use paddler_types::request_params::continue_from_conversation_history_params::tool::tool_params::function_call::FunctionCall; use paddler_types::request_params::continue_from_conversation_history_params::tool::tool_params::function_call::function::Function; use paddler_types::request_params::continue_from_conversation_history_params::tool::tool_params::function_call::parameters::Parameters; use paddler_types::request_params::continue_from_conversation_history_params::tool::tool_params::function_call::parameters_schema::validated_parameters_schema::ValidatedParametersSchema; diff --git a/paddler_tests/tests/agent_rejects_tool_with_invalid_required_field_in_schema.rs b/paddler_tests/tests/agent_rejects_tool_with_invalid_required_field_in_schema.rs index e7cc39fc..15e4baf2 100644 --- a/paddler_tests/tests/agent_rejects_tool_with_invalid_required_field_in_schema.rs +++ b/paddler_tests/tests/agent_rejects_tool_with_invalid_required_field_in_schema.rs @@ -11,7 +11,7 @@ use paddler_types::conversation_message::ConversationMessage; use paddler_types::conversation_message_content::ConversationMessageContent; use paddler_types::request_params::continue_from_conversation_history_params::ContinueFromConversationHistoryParams; use paddler_types::request_params::continue_from_conversation_history_params::tool::Tool; -use paddler_types::request_params::continue_from_conversation_history_params::tool::tool_params::FunctionCall; +use paddler_types::request_params::continue_from_conversation_history_params::tool::tool_params::function_call::FunctionCall; use paddler_types::request_params::continue_from_conversation_history_params::tool::tool_params::function_call::function::Function; use paddler_types::request_params::continue_from_conversation_history_params::tool::tool_params::function_call::parameters::Parameters; use paddler_types::request_params::continue_from_conversation_history_params::tool::tool_params::function_call::parameters_schema::validated_parameters_schema::ValidatedParametersSchema; diff --git a/paddler_tests/tests/balancer_completes_in_flight_inference_during_model_switch.rs b/paddler_tests/tests/balancer_completes_in_flight_inference_during_model_switch.rs index dee67c7b..e0bce8e2 100644 --- a/paddler_tests/tests/balancer_completes_in_flight_inference_during_model_switch.rs +++ b/paddler_tests/tests/balancer_completes_in_flight_inference_during_model_switch.rs @@ -4,12 +4,16 @@ ))] use anyhow::Result; +use anyhow::anyhow; +use futures_util::StreamExt as _; use paddler_tests::collect_generated_tokens::collect_generated_tokens; use paddler_tests::inference_http_client::InferenceHttpClient; use paddler_tests::start_subprocess_cluster_with_qwen3::start_subprocess_cluster_with_qwen3; use paddler_types::agent_desired_model::AgentDesiredModel; use paddler_types::balancer_desired_state::BalancerDesiredState; use paddler_types::grammar_constraint::GrammarConstraint; +use paddler_types::inference_client::Message as InferenceMessage; +use paddler_types::inference_client::Response as InferenceResponse; use paddler_types::inference_parameters::InferenceParameters; use paddler_types::request_params::ContinueFromRawPromptParams; use reqwest::Client; @@ -24,7 +28,7 @@ async fn balancer_completes_in_flight_inference_during_model_switch() -> Result< let expected_output = "the quick brown fox jumps over the lazy dog"; - let stream = inference_client + let mut stream = inference_client .post_continue_from_raw_prompt(&ContinueFromRawPromptParams { grammar: Some(GrammarConstraint::Gbnf { grammar: format!("root ::= \"{expected_output}\""), @@ -35,7 +39,29 @@ async fn balancer_completes_in_flight_inference_during_model_switch() -> Result< }) .await?; - // Trigger model switch to a nonexistent path while the request is in flight + // Wait for the first generated-token message before triggering the model + // switch. This guarantees the agent has acquired its inference slot and + // entered the generating phase, so the agent's `drain_in_flight_requests` + // correctly waits for the in-flight request to finish before tearing + // down the arbiter. Without this wait, the model-switch can race the + // request through the scheduler queue: drain sees zero slots in use, + // returns immediately, the arbiter is shut down, and the queued request + // times out with no scheduler to process it. + let mut buffered_text = String::new(); + loop { + let next = stream + .next() + .await + .ok_or_else(|| anyhow!("inference stream ended before producing any token"))??; + if let InferenceMessage::Response(envelope) = next + && let InferenceResponse::GeneratedToken(token_result) = envelope.response + && let Some(token_text) = token_result.token_text() + { + buffered_text.push_str(token_text); + break; + } + } + let switch_state = BalancerDesiredState { chat_template_override: None, inference_parameters: InferenceParameters::default(), @@ -53,8 +79,11 @@ async fn balancer_completes_in_flight_inference_during_model_switch() -> Result< let collected = collect_generated_tokens(stream).await?; + let mut full_text = buffered_text; + full_text.push_str(&collected.text); + assert_eq!( - collected.text, expected_output, + full_text, expected_output, "grammar-constrained output must complete despite concurrent model switch" ); diff --git a/paddler_tests/tests/gemma4_internal_endpoint_emits_reasoning_tokens.rs b/paddler_tests/tests/gemma4_internal_endpoint_emits_reasoning_tokens.rs new file mode 100644 index 00000000..08253cd7 --- /dev/null +++ b/paddler_tests/tests/gemma4_internal_endpoint_emits_reasoning_tokens.rs @@ -0,0 +1,102 @@ +#![cfg(feature = "tests_that_use_llms")] + +use anyhow::Result; +use paddler_tests::collect_generated_tokens::collect_generated_tokens; +use paddler_tests::inference_http_client::InferenceHttpClient; +use paddler_tests::start_in_process_cluster_with_gemma_4::start_in_process_cluster_with_gemma_4; +use paddler_types::conversation_history::ConversationHistory; +use paddler_types::conversation_message::ConversationMessage; +use paddler_types::conversation_message_content::ConversationMessageContent; +use paddler_types::generated_token_result::GeneratedTokenResult; +use paddler_types::request_params::continue_from_conversation_history_params::ContinueFromConversationHistoryParams; +use reqwest::Client; + +#[serial_test::file_serial(model_load, path => "../target/model_load.lock")] +#[tokio::test(flavor = "multi_thread")] +async fn gemma4_internal_endpoint_emits_reasoning_tokens() -> Result<()> { + let cluster = start_in_process_cluster_with_gemma_4(1).await?; + + let inference_client = + InferenceHttpClient::new(Client::new(), cluster.addresses.inference_base_url()?); + + let stream = inference_client + .post_continue_from_conversation_history(&ContinueFromConversationHistoryParams { + add_generation_prompt: true, + conversation_history: ConversationHistory::new(vec![ConversationMessage { + content: ConversationMessageContent::Text( + "What is two plus two? Think step by step.".to_owned(), + ), + role: "user".to_owned(), + }]), + enable_thinking: true, + grammar: None, + max_tokens: 800, + parse_tool_calls: false, + tools: vec![], + }) + .await?; + + let collected = collect_generated_tokens(stream).await?; + + let reasoning_count = collected + .token_results + .iter() + .filter(|result| matches!(result, GeneratedTokenResult::ReasoningToken(_))) + .count(); + + assert!( + reasoning_count > 0, + "Gemma 4: expected at least one reasoning token from a `<|channel>thought` block (got {reasoning_count})" + ); + + let last = collected + .token_results + .last() + .ok_or_else(|| anyhow::anyhow!("no token results received"))?; + let GeneratedTokenResult::Done(summary) = last else { + anyhow::bail!("last result was not Done: {last:?}"); + }; + + assert!(summary.usage.prompt_tokens > 0); + assert!(summary.usage.reasoning_tokens > 0); + assert_eq!( + summary.usage.completion_tokens(), + summary.usage.content_tokens + + summary.usage.reasoning_tokens + + summary.usage.undeterminable_tokens + ); + + let reasoning_stream: String = collected + .token_results + .iter() + .filter_map(|result| match result { + GeneratedTokenResult::ReasoningToken(piece) => Some(piece.as_str()), + _ => None, + }) + .collect(); + let content_stream: String = collected + .token_results + .iter() + .filter_map(|result| match result { + GeneratedTokenResult::ContentToken(piece) => Some(piece.as_str()), + _ => None, + }) + .collect(); + + for forbidden in ["<|channel>thought", ""] { + assert!( + !reasoning_stream.contains(forbidden), + "Gemma 4: reasoning stream leaked marker {forbidden:?}; \ + reasoning_stream={reasoning_stream:?}" + ); + assert!( + !content_stream.contains(forbidden), + "Gemma 4: content stream leaked marker {forbidden:?}; \ + content_stream={content_stream:?}" + ); + } + + cluster.shutdown().await?; + + Ok(()) +} diff --git a/paddler_tests/tests/gemma4_internal_endpoint_emits_tool_call_parsed_event.rs b/paddler_tests/tests/gemma4_internal_endpoint_emits_tool_call_parsed_event.rs new file mode 100644 index 00000000..5f298c51 --- /dev/null +++ b/paddler_tests/tests/gemma4_internal_endpoint_emits_tool_call_parsed_event.rs @@ -0,0 +1,105 @@ +#![cfg(feature = "tests_that_use_llms")] + +use anyhow::Result; +use paddler_tests::collect_generated_tokens::collect_generated_tokens; +use paddler_tests::inference_http_client::InferenceHttpClient; +use paddler_tests::start_in_process_cluster_with_gemma_4::start_in_process_cluster_with_gemma_4; +use paddler_types::conversation_history::ConversationHistory; +use paddler_types::conversation_message::ConversationMessage; +use paddler_types::conversation_message_content::ConversationMessageContent; +use paddler_types::generated_token_result::GeneratedTokenResult; +use paddler_types::request_params::continue_from_conversation_history_params::ContinueFromConversationHistoryParams; +use paddler_types::request_params::continue_from_conversation_history_params::tool::Tool; +use paddler_types::request_params::continue_from_conversation_history_params::tool::tool_params::function_call::FunctionCall; +use paddler_types::request_params::continue_from_conversation_history_params::tool::tool_params::function_call::function::Function; +use paddler_types::request_params::continue_from_conversation_history_params::tool::tool_params::function_call::parameters::Parameters; +use paddler_types::request_params::continue_from_conversation_history_params::tool::tool_params::function_call::parameters_schema::validated_parameters_schema::ValidatedParametersSchema; +use reqwest::Client; +use serde_json::Map; +use serde_json::Value; + +#[serial_test::file_serial(model_load, path => "../target/model_load.lock")] +#[tokio::test(flavor = "multi_thread")] +async fn gemma4_internal_endpoint_emits_tool_call_parsed_event() -> Result<()> { + let cluster = start_in_process_cluster_with_gemma_4(1).await?; + + let inference_client = + InferenceHttpClient::new(Client::new(), cluster.addresses.inference_base_url()?); + + let mut location_properties = Map::new(); + location_properties.insert( + "location".to_owned(), + serde_json::json!({"type": "string", "description": "The city name"}), + ); + + let stream = inference_client + .post_continue_from_conversation_history(&ContinueFromConversationHistoryParams { + add_generation_prompt: true, + conversation_history: ConversationHistory::new(vec![ConversationMessage { + content: ConversationMessageContent::Text( + "What is the weather in Paris? Use the get_weather tool to find out." + .to_owned(), + ), + role: "user".to_owned(), + }]), + enable_thinking: false, + grammar: None, + max_tokens: 400, + parse_tool_calls: true, + tools: vec![Tool::Function(FunctionCall { + function: Function { + name: "get_weather".to_owned(), + description: "Get the current weather for a location".to_owned(), + parameters: Parameters::Schema(ValidatedParametersSchema { + schema_type: "object".to_owned(), + properties: Some(location_properties), + required: Some(vec!["location".to_owned()]), + additional_properties: Some(Value::Bool(false)), + }), + }, + })], + }) + .await?; + + let collected = collect_generated_tokens(stream).await?; + + let parsed_events: Vec<&Vec> = collected + .token_results + .iter() + .filter_map(|event| match event { + GeneratedTokenResult::ToolCallParsed(parsed) => Some(parsed), + _ => None, + }) + .collect(); + + assert!( + !parsed_events.is_empty(), + "Gemma 4: expected at least one ToolCallParsed event; got tokens:\n{}", + collected.text + ); + + let first_call = parsed_events + .iter() + .flat_map(|calls| calls.iter()) + .next() + .ok_or_else(|| anyhow::anyhow!("no parsed tool calls in any event"))?; + + assert_eq!(first_call.name, "get_weather"); + let location = match &first_call.arguments { + llama_cpp_bindings::ToolCallArguments::ValidJson(value) => { + value.get("location").cloned() + } + llama_cpp_bindings::ToolCallArguments::InvalidJson(raw) => { + anyhow::bail!("expected valid JSON arguments, got InvalidJson: {raw}"); + } + }; + assert!( + location.is_some(), + "Gemma 4: arguments missing location: {:?}", + first_call.arguments + ); + + cluster.shutdown().await?; + + Ok(()) +} diff --git a/paddler_tests/tests/gemma4_internal_endpoint_with_thinking_disabled_emits_only_content_tokens.rs b/paddler_tests/tests/gemma4_internal_endpoint_with_thinking_disabled_emits_only_content_tokens.rs new file mode 100644 index 00000000..11bdc14b --- /dev/null +++ b/paddler_tests/tests/gemma4_internal_endpoint_with_thinking_disabled_emits_only_content_tokens.rs @@ -0,0 +1,132 @@ +#![cfg(feature = "tests_that_use_llms")] + +use anyhow::Result; +use paddler_tests::collect_generated_tokens::collect_generated_tokens; +use paddler_tests::inference_http_client::InferenceHttpClient; +use paddler_tests::start_in_process_cluster_with_gemma_4::start_in_process_cluster_with_gemma_4; +use paddler_types::conversation_history::ConversationHistory; +use paddler_types::conversation_message::ConversationMessage; +use paddler_types::conversation_message_content::ConversationMessageContent; +use paddler_types::generated_token_result::GeneratedTokenResult; +use paddler_types::request_params::continue_from_conversation_history_params::ContinueFromConversationHistoryParams; +use reqwest::Client; + +#[serial_test::file_serial(model_load, path => "../target/model_load.lock")] +#[tokio::test(flavor = "multi_thread")] +async fn gemma4_internal_endpoint_with_thinking_disabled_emits_only_content_tokens() -> Result<()> { + let cluster = start_in_process_cluster_with_gemma_4(1).await?; + + let inference_client = + InferenceHttpClient::new(Client::new(), cluster.addresses.inference_base_url()?); + + let stream = inference_client + .post_continue_from_conversation_history(&ContinueFromConversationHistoryParams { + add_generation_prompt: true, + conversation_history: ConversationHistory::new(vec![ConversationMessage { + content: ConversationMessageContent::Text("What is two plus two?".to_owned()), + role: "user".to_owned(), + }]), + enable_thinking: false, + grammar: None, + max_tokens: 200, + parse_tool_calls: false, + tools: vec![], + }) + .await?; + + let collected = collect_generated_tokens(stream).await?; + + let reasoning_count = collected + .token_results + .iter() + .filter(|result| matches!(result, GeneratedTokenResult::ReasoningToken(_))) + .count(); + let content_count = collected + .token_results + .iter() + .filter(|result| matches!(result, GeneratedTokenResult::ContentToken(_))) + .count(); + let undeterminable_count = collected + .token_results + .iter() + .filter(|result| matches!(result, GeneratedTokenResult::UndeterminableToken(_))) + .count(); + + assert_eq!( + reasoning_count, 0, + "Gemma 4 thinking-disabled: classifier must not stream any reasoning tokens \ + (got reasoning_count={reasoning_count}, content_count={content_count}, \ + undeterminable_count={undeterminable_count})" + ); + assert_eq!( + undeterminable_count, 0, + "Gemma 4 thinking-disabled: prompt-token replay must move section to Content \ + before generation, so no UndeterminableToken may stream; \ + (got reasoning_count={reasoning_count}, content_count={content_count}, \ + undeterminable_count={undeterminable_count})" + ); + assert!( + content_count > 0, + "Gemma 4 thinking-disabled: classifier must stream at least one content token" + ); + + let last = collected + .token_results + .last() + .ok_or_else(|| anyhow::anyhow!("no token results received"))?; + let GeneratedTokenResult::Done(summary) = last else { + anyhow::bail!("last result was not Done: {last:?}"); + }; + + assert!(summary.usage.prompt_tokens > 0); + assert_eq!( + summary.usage.reasoning_tokens, 0, + "Gemma 4 thinking-disabled: usage.reasoning_tokens must be zero; usage={:?}", + summary.usage + ); + assert_eq!( + summary.usage.undeterminable_tokens, 0, + "Gemma 4 thinking-disabled: usage.undeterminable_tokens must be zero; usage={:?}", + summary.usage + ); + assert_eq!( + summary.usage.completion_tokens(), + summary.usage.content_tokens, + "Gemma 4 thinking-disabled: completion tokens equal content tokens since \ + reasoning and undeterminable are zero" + ); + + let reasoning_stream: String = collected + .token_results + .iter() + .filter_map(|result| match result { + GeneratedTokenResult::ReasoningToken(piece) => Some(piece.as_str()), + _ => None, + }) + .collect(); + let content_stream: String = collected + .token_results + .iter() + .filter_map(|result| match result { + GeneratedTokenResult::ContentToken(piece) => Some(piece.as_str()), + _ => None, + }) + .collect(); + + for forbidden in ["<|channel>thought", ""] { + assert!( + !reasoning_stream.contains(forbidden), + "Gemma 4 thinking-disabled: reasoning stream leaked marker {forbidden:?}; \ + reasoning_stream={reasoning_stream:?}" + ); + assert!( + !content_stream.contains(forbidden), + "Gemma 4 thinking-disabled: content stream leaked marker {forbidden:?}; \ + content_stream={content_stream:?}" + ); + } + + cluster.shutdown().await?; + + Ok(()) +} diff --git a/paddler_tests/tests/mistral3_internal_endpoint_emits_reasoning_tokens.rs b/paddler_tests/tests/mistral3_internal_endpoint_emits_reasoning_tokens.rs new file mode 100644 index 00000000..2451eee3 --- /dev/null +++ b/paddler_tests/tests/mistral3_internal_endpoint_emits_reasoning_tokens.rs @@ -0,0 +1,102 @@ +#![cfg(feature = "tests_that_use_llms")] + +use anyhow::Result; +use paddler_tests::collect_generated_tokens::collect_generated_tokens; +use paddler_tests::inference_http_client::InferenceHttpClient; +use paddler_tests::start_in_process_cluster_with_ministral_3::start_in_process_cluster_with_ministral_3; +use paddler_types::conversation_history::ConversationHistory; +use paddler_types::conversation_message::ConversationMessage; +use paddler_types::conversation_message_content::ConversationMessageContent; +use paddler_types::generated_token_result::GeneratedTokenResult; +use paddler_types::request_params::continue_from_conversation_history_params::ContinueFromConversationHistoryParams; +use reqwest::Client; + +#[serial_test::file_serial(model_load, path => "../target/model_load.lock")] +#[tokio::test(flavor = "multi_thread")] +async fn mistral3_internal_endpoint_emits_reasoning_tokens() -> Result<()> { + let cluster = start_in_process_cluster_with_ministral_3(1).await?; + + let inference_client = + InferenceHttpClient::new(Client::new(), cluster.addresses.inference_base_url()?); + + let stream = inference_client + .post_continue_from_conversation_history(&ContinueFromConversationHistoryParams { + add_generation_prompt: true, + conversation_history: ConversationHistory::new(vec![ConversationMessage { + content: ConversationMessageContent::Text( + "What is two plus two? Think step by step.".to_owned(), + ), + role: "user".to_owned(), + }]), + enable_thinking: true, + grammar: None, + max_tokens: 800, + parse_tool_calls: false, + tools: vec![], + }) + .await?; + + let collected = collect_generated_tokens(stream).await?; + + let reasoning_count = collected + .token_results + .iter() + .filter(|result| matches!(result, GeneratedTokenResult::ReasoningToken(_))) + .count(); + + assert!( + reasoning_count > 0, + "Mistral 3: expected at least one reasoning token from a [THINK]-emitting model (got {reasoning_count})" + ); + + let last = collected + .token_results + .last() + .ok_or_else(|| anyhow::anyhow!("no token results received"))?; + let GeneratedTokenResult::Done(summary) = last else { + anyhow::bail!("last result was not Done: {last:?}"); + }; + + assert!(summary.usage.prompt_tokens > 0); + assert!(summary.usage.reasoning_tokens > 0); + assert_eq!( + summary.usage.completion_tokens(), + summary.usage.content_tokens + + summary.usage.reasoning_tokens + + summary.usage.undeterminable_tokens + ); + + let reasoning_stream: String = collected + .token_results + .iter() + .filter_map(|result| match result { + GeneratedTokenResult::ReasoningToken(piece) => Some(piece.as_str()), + _ => None, + }) + .collect(); + let content_stream: String = collected + .token_results + .iter() + .filter_map(|result| match result { + GeneratedTokenResult::ContentToken(piece) => Some(piece.as_str()), + _ => None, + }) + .collect(); + + for forbidden in ["[THINK]", "[/THINK]"] { + assert!( + !reasoning_stream.contains(forbidden), + "Mistral 3: reasoning stream leaked marker {forbidden:?}; \ + reasoning_stream={reasoning_stream:?}" + ); + assert!( + !content_stream.contains(forbidden), + "Mistral 3: content stream leaked marker {forbidden:?}; \ + content_stream={content_stream:?}" + ); + } + + cluster.shutdown().await?; + + Ok(()) +} diff --git a/paddler_tests/tests/mistral3_internal_endpoint_emits_tool_call_parsed_event.rs b/paddler_tests/tests/mistral3_internal_endpoint_emits_tool_call_parsed_event.rs new file mode 100644 index 00000000..2ee2e73e --- /dev/null +++ b/paddler_tests/tests/mistral3_internal_endpoint_emits_tool_call_parsed_event.rs @@ -0,0 +1,105 @@ +#![cfg(feature = "tests_that_use_llms")] + +use anyhow::Result; +use paddler_tests::collect_generated_tokens::collect_generated_tokens; +use paddler_tests::inference_http_client::InferenceHttpClient; +use paddler_tests::start_in_process_cluster_with_ministral_3::start_in_process_cluster_with_ministral_3; +use paddler_types::conversation_history::ConversationHistory; +use paddler_types::conversation_message::ConversationMessage; +use paddler_types::conversation_message_content::ConversationMessageContent; +use paddler_types::generated_token_result::GeneratedTokenResult; +use paddler_types::request_params::continue_from_conversation_history_params::ContinueFromConversationHistoryParams; +use paddler_types::request_params::continue_from_conversation_history_params::tool::Tool; +use paddler_types::request_params::continue_from_conversation_history_params::tool::tool_params::function_call::FunctionCall; +use paddler_types::request_params::continue_from_conversation_history_params::tool::tool_params::function_call::function::Function; +use paddler_types::request_params::continue_from_conversation_history_params::tool::tool_params::function_call::parameters::Parameters; +use paddler_types::request_params::continue_from_conversation_history_params::tool::tool_params::function_call::parameters_schema::validated_parameters_schema::ValidatedParametersSchema; +use reqwest::Client; +use serde_json::Map; +use serde_json::Value; + +#[serial_test::file_serial(model_load, path => "../target/model_load.lock")] +#[tokio::test(flavor = "multi_thread")] +async fn mistral3_internal_endpoint_emits_tool_call_parsed_event() -> Result<()> { + let cluster = start_in_process_cluster_with_ministral_3(1).await?; + + let inference_client = + InferenceHttpClient::new(Client::new(), cluster.addresses.inference_base_url()?); + + let mut location_properties = Map::new(); + location_properties.insert( + "location".to_owned(), + serde_json::json!({"type": "string", "description": "The city name"}), + ); + + let stream = inference_client + .post_continue_from_conversation_history(&ContinueFromConversationHistoryParams { + add_generation_prompt: true, + conversation_history: ConversationHistory::new(vec![ConversationMessage { + content: ConversationMessageContent::Text( + "What is the weather in Paris? Use the get_weather tool to find out." + .to_owned(), + ), + role: "user".to_owned(), + }]), + enable_thinking: false, + grammar: None, + max_tokens: 400, + parse_tool_calls: true, + tools: vec![Tool::Function(FunctionCall { + function: Function { + name: "get_weather".to_owned(), + description: "Get the current weather for a location".to_owned(), + parameters: Parameters::Schema(ValidatedParametersSchema { + schema_type: "object".to_owned(), + properties: Some(location_properties), + required: Some(vec!["location".to_owned()]), + additional_properties: Some(Value::Bool(false)), + }), + }, + })], + }) + .await?; + + let collected = collect_generated_tokens(stream).await?; + + let parsed_events: Vec<&Vec> = collected + .token_results + .iter() + .filter_map(|event| match event { + GeneratedTokenResult::ToolCallParsed(parsed) => Some(parsed), + _ => None, + }) + .collect(); + + assert!( + !parsed_events.is_empty(), + "Mistral 3: expected at least one ToolCallParsed event; got tokens:\n{}", + collected.text + ); + + let first_call = parsed_events + .iter() + .flat_map(|calls| calls.iter()) + .next() + .ok_or_else(|| anyhow::anyhow!("no parsed tool calls in any event"))?; + + assert_eq!(first_call.name, "get_weather"); + let location = match &first_call.arguments { + llama_cpp_bindings::ToolCallArguments::ValidJson(value) => { + value.get("location").cloned() + } + llama_cpp_bindings::ToolCallArguments::InvalidJson(raw) => { + anyhow::bail!("expected valid JSON arguments, got InvalidJson: {raw}"); + } + }; + assert!( + location.is_some(), + "Mistral 3: arguments missing location: {:?}", + first_call.arguments + ); + + cluster.shutdown().await?; + + Ok(()) +} diff --git a/paddler_tests/tests/mistral3_internal_endpoint_with_thinking_disabled_emits_only_content_tokens.rs b/paddler_tests/tests/mistral3_internal_endpoint_with_thinking_disabled_emits_only_content_tokens.rs new file mode 100644 index 00000000..74f23dd8 --- /dev/null +++ b/paddler_tests/tests/mistral3_internal_endpoint_with_thinking_disabled_emits_only_content_tokens.rs @@ -0,0 +1,133 @@ +#![cfg(feature = "tests_that_use_llms")] + +use anyhow::Result; +use paddler_tests::collect_generated_tokens::collect_generated_tokens; +use paddler_tests::inference_http_client::InferenceHttpClient; +use paddler_tests::start_in_process_cluster_with_ministral_3::start_in_process_cluster_with_ministral_3; +use paddler_types::conversation_history::ConversationHistory; +use paddler_types::conversation_message::ConversationMessage; +use paddler_types::conversation_message_content::ConversationMessageContent; +use paddler_types::generated_token_result::GeneratedTokenResult; +use paddler_types::request_params::continue_from_conversation_history_params::ContinueFromConversationHistoryParams; +use reqwest::Client; + +#[serial_test::file_serial(model_load, path => "../target/model_load.lock")] +#[tokio::test(flavor = "multi_thread")] +async fn mistral3_internal_endpoint_with_thinking_disabled_emits_only_content_tokens() -> Result<()> +{ + let cluster = start_in_process_cluster_with_ministral_3(1).await?; + + let inference_client = + InferenceHttpClient::new(Client::new(), cluster.addresses.inference_base_url()?); + + let stream = inference_client + .post_continue_from_conversation_history(&ContinueFromConversationHistoryParams { + add_generation_prompt: true, + conversation_history: ConversationHistory::new(vec![ConversationMessage { + content: ConversationMessageContent::Text("What is two plus two?".to_owned()), + role: "user".to_owned(), + }]), + enable_thinking: false, + grammar: None, + max_tokens: 200, + parse_tool_calls: false, + tools: vec![], + }) + .await?; + + let collected = collect_generated_tokens(stream).await?; + + let reasoning_count = collected + .token_results + .iter() + .filter(|result| matches!(result, GeneratedTokenResult::ReasoningToken(_))) + .count(); + let content_count = collected + .token_results + .iter() + .filter(|result| matches!(result, GeneratedTokenResult::ContentToken(_))) + .count(); + let undeterminable_count = collected + .token_results + .iter() + .filter(|result| matches!(result, GeneratedTokenResult::UndeterminableToken(_))) + .count(); + + assert_eq!( + reasoning_count, 0, + "Mistral 3 thinking-disabled: classifier must not stream any reasoning tokens \ + (got reasoning_count={reasoning_count}, content_count={content_count}, \ + undeterminable_count={undeterminable_count})" + ); + assert_eq!( + undeterminable_count, 0, + "Mistral 3 thinking-disabled: prompt-token replay must move section to Content \ + before generation, so no UndeterminableToken may stream; \ + (got reasoning_count={reasoning_count}, content_count={content_count}, \ + undeterminable_count={undeterminable_count})" + ); + assert!( + content_count > 0, + "Mistral 3 thinking-disabled: classifier must stream at least one content token" + ); + + let last = collected + .token_results + .last() + .ok_or_else(|| anyhow::anyhow!("no token results received"))?; + let GeneratedTokenResult::Done(summary) = last else { + anyhow::bail!("last result was not Done: {last:?}"); + }; + + assert!(summary.usage.prompt_tokens > 0); + assert_eq!( + summary.usage.reasoning_tokens, 0, + "Mistral 3 thinking-disabled: usage.reasoning_tokens must be zero; usage={:?}", + summary.usage + ); + assert_eq!( + summary.usage.undeterminable_tokens, 0, + "Mistral 3 thinking-disabled: usage.undeterminable_tokens must be zero; usage={:?}", + summary.usage + ); + assert_eq!( + summary.usage.completion_tokens(), + summary.usage.content_tokens, + "Mistral 3 thinking-disabled: completion tokens equal content tokens since \ + reasoning and undeterminable are zero" + ); + + let reasoning_stream: String = collected + .token_results + .iter() + .filter_map(|result| match result { + GeneratedTokenResult::ReasoningToken(piece) => Some(piece.as_str()), + _ => None, + }) + .collect(); + let content_stream: String = collected + .token_results + .iter() + .filter_map(|result| match result { + GeneratedTokenResult::ContentToken(piece) => Some(piece.as_str()), + _ => None, + }) + .collect(); + + for forbidden in ["[THINK]", "[/THINK]"] { + assert!( + !reasoning_stream.contains(forbidden), + "Mistral 3 thinking-disabled: reasoning stream leaked marker {forbidden:?}; \ + reasoning_stream={reasoning_stream:?}" + ); + assert!( + !content_stream.contains(forbidden), + "Mistral 3 thinking-disabled: content stream leaked marker {forbidden:?}; \ + content_stream={content_stream:?}" + ); + } + + cluster.shutdown().await?; + + Ok(()) +} diff --git a/paddler_tests/tests/qwen35_internal_endpoint_emits_tool_call_parsed_event.rs b/paddler_tests/tests/qwen35_internal_endpoint_emits_tool_call_parsed_event.rs new file mode 100644 index 00000000..52720633 --- /dev/null +++ b/paddler_tests/tests/qwen35_internal_endpoint_emits_tool_call_parsed_event.rs @@ -0,0 +1,105 @@ +#![cfg(feature = "tests_that_use_llms")] + +use anyhow::Result; +use paddler_tests::collect_generated_tokens::collect_generated_tokens; +use paddler_tests::inference_http_client::InferenceHttpClient; +use paddler_tests::start_in_process_cluster_with_qwen3_5::start_in_process_cluster_with_qwen3_5; +use paddler_types::conversation_history::ConversationHistory; +use paddler_types::conversation_message::ConversationMessage; +use paddler_types::conversation_message_content::ConversationMessageContent; +use paddler_types::generated_token_result::GeneratedTokenResult; +use paddler_types::request_params::continue_from_conversation_history_params::ContinueFromConversationHistoryParams; +use paddler_types::request_params::continue_from_conversation_history_params::tool::Tool; +use paddler_types::request_params::continue_from_conversation_history_params::tool::tool_params::function_call::FunctionCall; +use paddler_types::request_params::continue_from_conversation_history_params::tool::tool_params::function_call::function::Function; +use paddler_types::request_params::continue_from_conversation_history_params::tool::tool_params::function_call::parameters::Parameters; +use paddler_types::request_params::continue_from_conversation_history_params::tool::tool_params::function_call::parameters_schema::validated_parameters_schema::ValidatedParametersSchema; +use reqwest::Client; +use serde_json::Map; +use serde_json::Value; + +#[serial_test::file_serial(model_load, path => "../target/model_load.lock")] +#[tokio::test(flavor = "multi_thread")] +async fn qwen35_internal_endpoint_emits_tool_call_parsed_event() -> Result<()> { + let cluster = start_in_process_cluster_with_qwen3_5(1, false).await?; + + let inference_client = + InferenceHttpClient::new(Client::new(), cluster.addresses.inference_base_url()?); + + let mut location_properties = Map::new(); + location_properties.insert( + "location".to_owned(), + serde_json::json!({"type": "string", "description": "The city name"}), + ); + + let stream = inference_client + .post_continue_from_conversation_history(&ContinueFromConversationHistoryParams { + add_generation_prompt: true, + conversation_history: ConversationHistory::new(vec![ConversationMessage { + content: ConversationMessageContent::Text( + "What is the weather in Paris? Use the get_weather tool to find out." + .to_owned(), + ), + role: "user".to_owned(), + }]), + enable_thinking: false, + grammar: None, + max_tokens: 400, + parse_tool_calls: true, + tools: vec![Tool::Function(FunctionCall { + function: Function { + name: "get_weather".to_owned(), + description: "Get the current weather for a location".to_owned(), + parameters: Parameters::Schema(ValidatedParametersSchema { + schema_type: "object".to_owned(), + properties: Some(location_properties), + required: Some(vec!["location".to_owned()]), + additional_properties: Some(Value::Bool(false)), + }), + }, + })], + }) + .await?; + + let collected = collect_generated_tokens(stream).await?; + + let parsed_events: Vec<&Vec> = collected + .token_results + .iter() + .filter_map(|event| match event { + GeneratedTokenResult::ToolCallParsed(parsed) => Some(parsed), + _ => None, + }) + .collect(); + + assert!( + !parsed_events.is_empty(), + "Qwen3.5: expected at least one ToolCallParsed event; got tokens:\n{}", + collected.text + ); + + let first_call = parsed_events + .iter() + .flat_map(|calls| calls.iter()) + .next() + .ok_or_else(|| anyhow::anyhow!("no parsed tool calls in any event"))?; + + assert_eq!(first_call.name, "get_weather"); + let location = match &first_call.arguments { + llama_cpp_bindings::ToolCallArguments::ValidJson(value) => { + value.get("location").cloned() + } + llama_cpp_bindings::ToolCallArguments::InvalidJson(raw) => { + anyhow::bail!("expected valid JSON arguments, got InvalidJson: {raw}"); + } + }; + assert!( + location.is_some(), + "Qwen3.5: arguments missing location: {:?}", + first_call.arguments + ); + + cluster.shutdown().await?; + + Ok(()) +} diff --git a/paddler_tests/tests/qwen35_internal_endpoint_with_thinking_disabled_emits_only_content_tokens.rs b/paddler_tests/tests/qwen35_internal_endpoint_with_thinking_disabled_emits_only_content_tokens.rs new file mode 100644 index 00000000..984d201a --- /dev/null +++ b/paddler_tests/tests/qwen35_internal_endpoint_with_thinking_disabled_emits_only_content_tokens.rs @@ -0,0 +1,132 @@ +#![cfg(feature = "tests_that_use_llms")] + +use anyhow::Result; +use paddler_tests::collect_generated_tokens::collect_generated_tokens; +use paddler_tests::inference_http_client::InferenceHttpClient; +use paddler_tests::start_in_process_cluster_with_qwen3_5::start_in_process_cluster_with_qwen3_5; +use paddler_types::conversation_history::ConversationHistory; +use paddler_types::conversation_message::ConversationMessage; +use paddler_types::conversation_message_content::ConversationMessageContent; +use paddler_types::generated_token_result::GeneratedTokenResult; +use paddler_types::request_params::continue_from_conversation_history_params::ContinueFromConversationHistoryParams; +use reqwest::Client; + +#[serial_test::file_serial(model_load, path => "../target/model_load.lock")] +#[tokio::test(flavor = "multi_thread")] +async fn qwen35_internal_endpoint_with_thinking_disabled_emits_only_content_tokens() -> Result<()> { + let cluster = start_in_process_cluster_with_qwen3_5(1, false).await?; + + let inference_client = + InferenceHttpClient::new(Client::new(), cluster.addresses.inference_base_url()?); + + let stream = inference_client + .post_continue_from_conversation_history(&ContinueFromConversationHistoryParams { + add_generation_prompt: true, + conversation_history: ConversationHistory::new(vec![ConversationMessage { + content: ConversationMessageContent::Text("What is two plus two?".to_owned()), + role: "user".to_owned(), + }]), + enable_thinking: false, + grammar: None, + max_tokens: 200, + parse_tool_calls: false, + tools: vec![], + }) + .await?; + + let collected = collect_generated_tokens(stream).await?; + + let reasoning_count = collected + .token_results + .iter() + .filter(|result| matches!(result, GeneratedTokenResult::ReasoningToken(_))) + .count(); + let content_count = collected + .token_results + .iter() + .filter(|result| matches!(result, GeneratedTokenResult::ContentToken(_))) + .count(); + let undeterminable_count = collected + .token_results + .iter() + .filter(|result| matches!(result, GeneratedTokenResult::UndeterminableToken(_))) + .count(); + + assert_eq!( + reasoning_count, 0, + "Qwen3.5 thinking-disabled: classifier must not stream any reasoning tokens \ + (got reasoning_count={reasoning_count}, content_count={content_count}, \ + undeterminable_count={undeterminable_count})" + ); + assert_eq!( + undeterminable_count, 0, + "Qwen3.5 thinking-disabled: prompt-token replay must move section to Content \ + before generation, so no UndeterminableToken may stream; \ + (got reasoning_count={reasoning_count}, content_count={content_count}, \ + undeterminable_count={undeterminable_count})" + ); + assert!( + content_count > 0, + "Qwen3.5 thinking-disabled: classifier must stream at least one content token" + ); + + let last = collected + .token_results + .last() + .ok_or_else(|| anyhow::anyhow!("no token results received"))?; + let GeneratedTokenResult::Done(summary) = last else { + anyhow::bail!("last result was not Done: {last:?}"); + }; + + assert!(summary.usage.prompt_tokens > 0); + assert_eq!( + summary.usage.reasoning_tokens, 0, + "Qwen3.5 thinking-disabled: usage.reasoning_tokens must be zero; usage={:?}", + summary.usage + ); + assert_eq!( + summary.usage.undeterminable_tokens, 0, + "Qwen3.5 thinking-disabled: usage.undeterminable_tokens must be zero; usage={:?}", + summary.usage + ); + assert_eq!( + summary.usage.completion_tokens(), + summary.usage.content_tokens, + "Qwen3.5 thinking-disabled: completion tokens equal content tokens since \ + reasoning and undeterminable are zero" + ); + + let reasoning_stream: String = collected + .token_results + .iter() + .filter_map(|result| match result { + GeneratedTokenResult::ReasoningToken(piece) => Some(piece.as_str()), + _ => None, + }) + .collect(); + let content_stream: String = collected + .token_results + .iter() + .filter_map(|result| match result { + GeneratedTokenResult::ContentToken(piece) => Some(piece.as_str()), + _ => None, + }) + .collect(); + + for forbidden in ["", ""] { + assert!( + !reasoning_stream.contains(forbidden), + "Qwen3.5 thinking-disabled: reasoning stream leaked marker {forbidden:?}; \ + reasoning_stream={reasoning_stream:?}" + ); + assert!( + !content_stream.contains(forbidden), + "Qwen3.5 thinking-disabled: content stream leaked marker {forbidden:?}; \ + content_stream={content_stream:?}" + ); + } + + cluster.shutdown().await?; + + Ok(()) +} diff --git a/paddler_tests/tests/qwen35_internal_endpoint_with_thinking_enabled_emits_reasoning_tokens.rs b/paddler_tests/tests/qwen35_internal_endpoint_with_thinking_enabled_emits_reasoning_tokens.rs new file mode 100644 index 00000000..8302da3e --- /dev/null +++ b/paddler_tests/tests/qwen35_internal_endpoint_with_thinking_enabled_emits_reasoning_tokens.rs @@ -0,0 +1,102 @@ +#![cfg(feature = "tests_that_use_llms")] + +use anyhow::Result; +use paddler_tests::collect_generated_tokens::collect_generated_tokens; +use paddler_tests::inference_http_client::InferenceHttpClient; +use paddler_tests::start_in_process_cluster_with_qwen3_5::start_in_process_cluster_with_qwen3_5; +use paddler_types::conversation_history::ConversationHistory; +use paddler_types::conversation_message::ConversationMessage; +use paddler_types::conversation_message_content::ConversationMessageContent; +use paddler_types::generated_token_result::GeneratedTokenResult; +use paddler_types::request_params::continue_from_conversation_history_params::ContinueFromConversationHistoryParams; +use reqwest::Client; + +#[serial_test::file_serial(model_load, path => "../target/model_load.lock")] +#[tokio::test(flavor = "multi_thread")] +async fn qwen35_internal_endpoint_with_thinking_enabled_emits_reasoning_tokens() -> Result<()> { + let cluster = start_in_process_cluster_with_qwen3_5(1, false).await?; + + let inference_client = + InferenceHttpClient::new(Client::new(), cluster.addresses.inference_base_url()?); + + let stream = inference_client + .post_continue_from_conversation_history(&ContinueFromConversationHistoryParams { + add_generation_prompt: true, + conversation_history: ConversationHistory::new(vec![ConversationMessage { + content: ConversationMessageContent::Text( + "What is two plus two? Think step by step.".to_owned(), + ), + role: "user".to_owned(), + }]), + enable_thinking: true, + grammar: None, + max_tokens: 600, + parse_tool_calls: false, + tools: vec![], + }) + .await?; + + let collected = collect_generated_tokens(stream).await?; + + let reasoning_count = collected + .token_results + .iter() + .filter(|result| matches!(result, GeneratedTokenResult::ReasoningToken(_))) + .count(); + + assert!( + reasoning_count > 0, + "Qwen3.5: expected at least one reasoning token when thinking is enabled (got {reasoning_count})" + ); + + let last = collected + .token_results + .last() + .ok_or_else(|| anyhow::anyhow!("no token results received"))?; + let GeneratedTokenResult::Done(summary) = last else { + anyhow::bail!("last result was not Done: {last:?}"); + }; + + assert!(summary.usage.prompt_tokens > 0); + assert!(summary.usage.reasoning_tokens > 0); + assert_eq!( + summary.usage.completion_tokens(), + summary.usage.content_tokens + + summary.usage.reasoning_tokens + + summary.usage.undeterminable_tokens + ); + + let reasoning_stream: String = collected + .token_results + .iter() + .filter_map(|result| match result { + GeneratedTokenResult::ReasoningToken(piece) => Some(piece.as_str()), + _ => None, + }) + .collect(); + let content_stream: String = collected + .token_results + .iter() + .filter_map(|result| match result { + GeneratedTokenResult::ContentToken(piece) => Some(piece.as_str()), + _ => None, + }) + .collect(); + + for forbidden in ["", ""] { + assert!( + !reasoning_stream.contains(forbidden), + "Qwen3.5: reasoning stream leaked marker {forbidden:?}; \ + reasoning_stream={reasoning_stream:?}" + ); + assert!( + !content_stream.contains(forbidden), + "Qwen3.5: content stream leaked marker {forbidden:?}; \ + content_stream={content_stream:?}" + ); + } + + cluster.shutdown().await?; + + Ok(()) +} diff --git a/paddler_tests/tests/qwen36_internal_endpoint_emits_tool_call_parsed_event.rs b/paddler_tests/tests/qwen36_internal_endpoint_emits_tool_call_parsed_event.rs new file mode 100644 index 00000000..0b46b217 --- /dev/null +++ b/paddler_tests/tests/qwen36_internal_endpoint_emits_tool_call_parsed_event.rs @@ -0,0 +1,105 @@ +#![cfg(feature = "tests_that_use_llms")] + +use anyhow::Result; +use paddler_tests::collect_generated_tokens::collect_generated_tokens; +use paddler_tests::inference_http_client::InferenceHttpClient; +use paddler_tests::start_in_process_cluster_with_qwen3_6::start_in_process_cluster_with_qwen3_6; +use paddler_types::conversation_history::ConversationHistory; +use paddler_types::conversation_message::ConversationMessage; +use paddler_types::conversation_message_content::ConversationMessageContent; +use paddler_types::generated_token_result::GeneratedTokenResult; +use paddler_types::request_params::continue_from_conversation_history_params::ContinueFromConversationHistoryParams; +use paddler_types::request_params::continue_from_conversation_history_params::tool::Tool; +use paddler_types::request_params::continue_from_conversation_history_params::tool::tool_params::function_call::FunctionCall; +use paddler_types::request_params::continue_from_conversation_history_params::tool::tool_params::function_call::function::Function; +use paddler_types::request_params::continue_from_conversation_history_params::tool::tool_params::function_call::parameters::Parameters; +use paddler_types::request_params::continue_from_conversation_history_params::tool::tool_params::function_call::parameters_schema::validated_parameters_schema::ValidatedParametersSchema; +use reqwest::Client; +use serde_json::Map; +use serde_json::Value; + +#[serial_test::file_serial(model_load, path => "../target/model_load.lock")] +#[tokio::test(flavor = "multi_thread")] +async fn qwen36_internal_endpoint_emits_tool_call_parsed_event() -> Result<()> { + let cluster = start_in_process_cluster_with_qwen3_6(1).await?; + + let inference_client = + InferenceHttpClient::new(Client::new(), cluster.addresses.inference_base_url()?); + + let mut location_properties = Map::new(); + location_properties.insert( + "location".to_owned(), + serde_json::json!({"type": "string", "description": "The city name"}), + ); + + let stream = inference_client + .post_continue_from_conversation_history(&ContinueFromConversationHistoryParams { + add_generation_prompt: true, + conversation_history: ConversationHistory::new(vec![ConversationMessage { + content: ConversationMessageContent::Text( + "What is the weather in Paris? Use the get_weather tool to find out." + .to_owned(), + ), + role: "user".to_owned(), + }]), + enable_thinking: false, + grammar: None, + max_tokens: 400, + parse_tool_calls: true, + tools: vec![Tool::Function(FunctionCall { + function: Function { + name: "get_weather".to_owned(), + description: "Get the current weather for a location".to_owned(), + parameters: Parameters::Schema(ValidatedParametersSchema { + schema_type: "object".to_owned(), + properties: Some(location_properties), + required: Some(vec!["location".to_owned()]), + additional_properties: Some(Value::Bool(false)), + }), + }, + })], + }) + .await?; + + let collected = collect_generated_tokens(stream).await?; + + let parsed_events: Vec<&Vec> = collected + .token_results + .iter() + .filter_map(|event| match event { + GeneratedTokenResult::ToolCallParsed(parsed) => Some(parsed), + _ => None, + }) + .collect(); + + assert!( + !parsed_events.is_empty(), + "Qwen3.6: expected at least one ToolCallParsed event; got tokens:\n{}", + collected.text + ); + + let first_call = parsed_events + .iter() + .flat_map(|calls| calls.iter()) + .next() + .ok_or_else(|| anyhow::anyhow!("no parsed tool calls in any event"))?; + + assert_eq!(first_call.name, "get_weather"); + let location = match &first_call.arguments { + llama_cpp_bindings::ToolCallArguments::ValidJson(value) => { + value.get("location").cloned() + } + llama_cpp_bindings::ToolCallArguments::InvalidJson(raw) => { + anyhow::bail!("expected valid JSON arguments, got InvalidJson: {raw}"); + } + }; + assert!( + location.is_some(), + "Qwen3.6: arguments missing location: {:?}", + first_call.arguments + ); + + cluster.shutdown().await?; + + Ok(()) +} diff --git a/paddler_tests/tests/qwen36_internal_endpoint_with_thinking_disabled_emits_only_content_tokens.rs b/paddler_tests/tests/qwen36_internal_endpoint_with_thinking_disabled_emits_only_content_tokens.rs new file mode 100644 index 00000000..58d8208d --- /dev/null +++ b/paddler_tests/tests/qwen36_internal_endpoint_with_thinking_disabled_emits_only_content_tokens.rs @@ -0,0 +1,132 @@ +#![cfg(feature = "tests_that_use_llms")] + +use anyhow::Result; +use paddler_tests::collect_generated_tokens::collect_generated_tokens; +use paddler_tests::inference_http_client::InferenceHttpClient; +use paddler_tests::start_in_process_cluster_with_qwen3_6::start_in_process_cluster_with_qwen3_6; +use paddler_types::conversation_history::ConversationHistory; +use paddler_types::conversation_message::ConversationMessage; +use paddler_types::conversation_message_content::ConversationMessageContent; +use paddler_types::generated_token_result::GeneratedTokenResult; +use paddler_types::request_params::continue_from_conversation_history_params::ContinueFromConversationHistoryParams; +use reqwest::Client; + +#[serial_test::file_serial(model_load, path => "../target/model_load.lock")] +#[tokio::test(flavor = "multi_thread")] +async fn qwen36_internal_endpoint_with_thinking_disabled_emits_only_content_tokens() -> Result<()> { + let cluster = start_in_process_cluster_with_qwen3_6(1).await?; + + let inference_client = + InferenceHttpClient::new(Client::new(), cluster.addresses.inference_base_url()?); + + let stream = inference_client + .post_continue_from_conversation_history(&ContinueFromConversationHistoryParams { + add_generation_prompt: true, + conversation_history: ConversationHistory::new(vec![ConversationMessage { + content: ConversationMessageContent::Text("What is two plus two?".to_owned()), + role: "user".to_owned(), + }]), + enable_thinking: false, + grammar: None, + max_tokens: 200, + parse_tool_calls: false, + tools: vec![], + }) + .await?; + + let collected = collect_generated_tokens(stream).await?; + + let reasoning_count = collected + .token_results + .iter() + .filter(|result| matches!(result, GeneratedTokenResult::ReasoningToken(_))) + .count(); + let content_count = collected + .token_results + .iter() + .filter(|result| matches!(result, GeneratedTokenResult::ContentToken(_))) + .count(); + let undeterminable_count = collected + .token_results + .iter() + .filter(|result| matches!(result, GeneratedTokenResult::UndeterminableToken(_))) + .count(); + + assert_eq!( + reasoning_count, 0, + "Qwen3.6 thinking-disabled: classifier must not stream any reasoning tokens \ + (got reasoning_count={reasoning_count}, content_count={content_count}, \ + undeterminable_count={undeterminable_count})" + ); + assert_eq!( + undeterminable_count, 0, + "Qwen3.6 thinking-disabled: prompt-token replay must move section to Content \ + before generation, so no UndeterminableToken may stream; \ + (got reasoning_count={reasoning_count}, content_count={content_count}, \ + undeterminable_count={undeterminable_count})" + ); + assert!( + content_count > 0, + "Qwen3.6 thinking-disabled: classifier must stream at least one content token" + ); + + let last = collected + .token_results + .last() + .ok_or_else(|| anyhow::anyhow!("no token results received"))?; + let GeneratedTokenResult::Done(summary) = last else { + anyhow::bail!("last result was not Done: {last:?}"); + }; + + assert!(summary.usage.prompt_tokens > 0); + assert_eq!( + summary.usage.reasoning_tokens, 0, + "Qwen3.6 thinking-disabled: usage.reasoning_tokens must be zero; usage={:?}", + summary.usage + ); + assert_eq!( + summary.usage.undeterminable_tokens, 0, + "Qwen3.6 thinking-disabled: usage.undeterminable_tokens must be zero; usage={:?}", + summary.usage + ); + assert_eq!( + summary.usage.completion_tokens(), + summary.usage.content_tokens, + "Qwen3.6 thinking-disabled: completion tokens equal content tokens since \ + reasoning and undeterminable are zero" + ); + + let reasoning_stream: String = collected + .token_results + .iter() + .filter_map(|result| match result { + GeneratedTokenResult::ReasoningToken(piece) => Some(piece.as_str()), + _ => None, + }) + .collect(); + let content_stream: String = collected + .token_results + .iter() + .filter_map(|result| match result { + GeneratedTokenResult::ContentToken(piece) => Some(piece.as_str()), + _ => None, + }) + .collect(); + + for forbidden in ["", ""] { + assert!( + !reasoning_stream.contains(forbidden), + "Qwen3.6 thinking-disabled: reasoning stream leaked marker {forbidden:?}; \ + reasoning_stream={reasoning_stream:?}" + ); + assert!( + !content_stream.contains(forbidden), + "Qwen3.6 thinking-disabled: content stream leaked marker {forbidden:?}; \ + content_stream={content_stream:?}" + ); + } + + cluster.shutdown().await?; + + Ok(()) +} diff --git a/paddler_tests/tests/qwen36_internal_endpoint_with_thinking_enabled_emits_reasoning_tokens.rs b/paddler_tests/tests/qwen36_internal_endpoint_with_thinking_enabled_emits_reasoning_tokens.rs new file mode 100644 index 00000000..697e23a7 --- /dev/null +++ b/paddler_tests/tests/qwen36_internal_endpoint_with_thinking_enabled_emits_reasoning_tokens.rs @@ -0,0 +1,102 @@ +#![cfg(feature = "tests_that_use_llms")] + +use anyhow::Result; +use paddler_tests::collect_generated_tokens::collect_generated_tokens; +use paddler_tests::inference_http_client::InferenceHttpClient; +use paddler_tests::start_in_process_cluster_with_qwen3_6::start_in_process_cluster_with_qwen3_6; +use paddler_types::conversation_history::ConversationHistory; +use paddler_types::conversation_message::ConversationMessage; +use paddler_types::conversation_message_content::ConversationMessageContent; +use paddler_types::generated_token_result::GeneratedTokenResult; +use paddler_types::request_params::continue_from_conversation_history_params::ContinueFromConversationHistoryParams; +use reqwest::Client; + +#[serial_test::file_serial(model_load, path => "../target/model_load.lock")] +#[tokio::test(flavor = "multi_thread")] +async fn qwen36_internal_endpoint_with_thinking_enabled_emits_reasoning_tokens() -> Result<()> { + let cluster = start_in_process_cluster_with_qwen3_6(1).await?; + + let inference_client = + InferenceHttpClient::new(Client::new(), cluster.addresses.inference_base_url()?); + + let stream = inference_client + .post_continue_from_conversation_history(&ContinueFromConversationHistoryParams { + add_generation_prompt: true, + conversation_history: ConversationHistory::new(vec![ConversationMessage { + content: ConversationMessageContent::Text( + "What is two plus two? Think step by step.".to_owned(), + ), + role: "user".to_owned(), + }]), + enable_thinking: true, + grammar: None, + max_tokens: 600, + parse_tool_calls: false, + tools: vec![], + }) + .await?; + + let collected = collect_generated_tokens(stream).await?; + + let reasoning_count = collected + .token_results + .iter() + .filter(|result| matches!(result, GeneratedTokenResult::ReasoningToken(_))) + .count(); + + assert!( + reasoning_count > 0, + "Qwen3.6: expected at least one reasoning token when thinking is enabled (got {reasoning_count})" + ); + + let last = collected + .token_results + .last() + .ok_or_else(|| anyhow::anyhow!("no token results received"))?; + let GeneratedTokenResult::Done(summary) = last else { + anyhow::bail!("last result was not Done: {last:?}"); + }; + + assert!(summary.usage.prompt_tokens > 0); + assert!(summary.usage.reasoning_tokens > 0); + assert_eq!( + summary.usage.completion_tokens(), + summary.usage.content_tokens + + summary.usage.reasoning_tokens + + summary.usage.undeterminable_tokens + ); + + let reasoning_stream: String = collected + .token_results + .iter() + .filter_map(|result| match result { + GeneratedTokenResult::ReasoningToken(piece) => Some(piece.as_str()), + _ => None, + }) + .collect(); + let content_stream: String = collected + .token_results + .iter() + .filter_map(|result| match result { + GeneratedTokenResult::ContentToken(piece) => Some(piece.as_str()), + _ => None, + }) + .collect(); + + for forbidden in ["", ""] { + assert!( + !reasoning_stream.contains(forbidden), + "Qwen3.6: reasoning stream leaked marker {forbidden:?}; \ + reasoning_stream={reasoning_stream:?}" + ); + assert!( + !content_stream.contains(forbidden), + "Qwen3.6: content stream leaked marker {forbidden:?}; \ + content_stream={content_stream:?}" + ); + } + + cluster.shutdown().await?; + + Ok(()) +} diff --git a/paddler_tests/tests/qwen3_internal_endpoint_emits_tool_call_parsed_event.rs b/paddler_tests/tests/qwen3_internal_endpoint_emits_tool_call_parsed_event.rs index 2cdbd70d..a3085366 100644 --- a/paddler_tests/tests/qwen3_internal_endpoint_emits_tool_call_parsed_event.rs +++ b/paddler_tests/tests/qwen3_internal_endpoint_emits_tool_call_parsed_event.rs @@ -10,7 +10,7 @@ use paddler_types::conversation_message_content::ConversationMessageContent; use paddler_types::generated_token_result::GeneratedTokenResult; use paddler_types::request_params::continue_from_conversation_history_params::ContinueFromConversationHistoryParams; use paddler_types::request_params::continue_from_conversation_history_params::tool::Tool; -use paddler_types::request_params::continue_from_conversation_history_params::tool::tool_params::FunctionCall; +use paddler_types::request_params::continue_from_conversation_history_params::tool::tool_params::function_call::FunctionCall; use paddler_types::request_params::continue_from_conversation_history_params::tool::tool_params::function_call::function::Function; use paddler_types::request_params::continue_from_conversation_history_params::tool::tool_params::function_call::parameters::Parameters; use paddler_types::request_params::continue_from_conversation_history_params::tool::tool_params::function_call::parameters_schema::validated_parameters_schema::ValidatedParametersSchema; @@ -63,7 +63,7 @@ async fn qwen3_internal_endpoint_emits_tool_call_parsed_event() -> Result<()> { let collected = collect_generated_tokens(stream).await?; - let parsed_events: Vec<&Vec> = collected + let parsed_events: Vec<&Vec> = collected .token_results .iter() .filter_map(|event| match event { @@ -86,10 +86,10 @@ async fn qwen3_internal_endpoint_emits_tool_call_parsed_event() -> Result<()> { assert_eq!(first_call.name, "get_weather"); let location = match &first_call.arguments { - paddler_types::parsed_tool_call::ToolCallArguments::ValidJson(value) => { + llama_cpp_bindings::ToolCallArguments::ValidJson(value) => { value.get("location").cloned() } - paddler_types::parsed_tool_call::ToolCallArguments::InvalidJson(raw) => { + llama_cpp_bindings::ToolCallArguments::InvalidJson(raw) => { anyhow::bail!("expected valid JSON arguments, got InvalidJson: {raw}"); } }; diff --git a/paddler_tests/tests/qwen3_internal_endpoint_emits_tool_call_tokens.rs b/paddler_tests/tests/qwen3_internal_endpoint_emits_tool_call_tokens.rs index 2ddb4826..e363138b 100644 --- a/paddler_tests/tests/qwen3_internal_endpoint_emits_tool_call_tokens.rs +++ b/paddler_tests/tests/qwen3_internal_endpoint_emits_tool_call_tokens.rs @@ -10,7 +10,7 @@ use paddler_types::conversation_message_content::ConversationMessageContent; use paddler_types::generated_token_result::GeneratedTokenResult; use paddler_types::request_params::continue_from_conversation_history_params::ContinueFromConversationHistoryParams; use paddler_types::request_params::continue_from_conversation_history_params::tool::Tool; -use paddler_types::request_params::continue_from_conversation_history_params::tool::tool_params::FunctionCall; +use paddler_types::request_params::continue_from_conversation_history_params::tool::tool_params::function_call::FunctionCall; use paddler_types::request_params::continue_from_conversation_history_params::tool::tool_params::function_call::function::Function; use paddler_types::request_params::continue_from_conversation_history_params::tool::tool_params::function_call::parameters::Parameters; use paddler_types::request_params::continue_from_conversation_history_params::tool::tool_params::function_call::parameters_schema::validated_parameters_schema::ValidatedParametersSchema; diff --git a/paddler_tests/tests/qwen3_internal_endpoint_tools_without_parse_flag_emit_only_raw_tokens.rs b/paddler_tests/tests/qwen3_internal_endpoint_tools_without_parse_flag_emit_only_raw_tokens.rs index 8385840d..ed74867c 100644 --- a/paddler_tests/tests/qwen3_internal_endpoint_tools_without_parse_flag_emit_only_raw_tokens.rs +++ b/paddler_tests/tests/qwen3_internal_endpoint_tools_without_parse_flag_emit_only_raw_tokens.rs @@ -10,7 +10,7 @@ use paddler_types::conversation_message_content::ConversationMessageContent; use paddler_types::generated_token_result::GeneratedTokenResult; use paddler_types::request_params::continue_from_conversation_history_params::ContinueFromConversationHistoryParams; use paddler_types::request_params::continue_from_conversation_history_params::tool::Tool; -use paddler_types::request_params::continue_from_conversation_history_params::tool::tool_params::FunctionCall; +use paddler_types::request_params::continue_from_conversation_history_params::tool::tool_params::function_call::FunctionCall; use paddler_types::request_params::continue_from_conversation_history_params::tool::tool_params::function_call::function::Function; use paddler_types::request_params::continue_from_conversation_history_params::tool::tool_params::function_call::parameters::Parameters; use paddler_types::request_params::continue_from_conversation_history_params::tool::tool_params::function_call::parameters_schema::validated_parameters_schema::ValidatedParametersSchema; diff --git a/paddler_types/src/generated_token_result.rs b/paddler_types/src/generated_token_result.rs index 03af3ae6..77baa845 100644 --- a/paddler_types/src/generated_token_result.rs +++ b/paddler_types/src/generated_token_result.rs @@ -1,8 +1,9 @@ use serde::Deserialize; use serde::Serialize; +use llama_cpp_bindings_types::ParsedToolCall; + use crate::generation_summary::GenerationSummary; -use crate::parsed_tool_call::ParsedToolCall; use crate::streamable_result::StreamableResult; #[derive(Debug, Deserialize, Serialize)] diff --git a/paddler_types/src/generation_summary.rs b/paddler_types/src/generation_summary.rs index 4e3b78d0..8592077c 100644 --- a/paddler_types/src/generation_summary.rs +++ b/paddler_types/src/generation_summary.rs @@ -1,7 +1,7 @@ use serde::Deserialize; use serde::Serialize; -use crate::token_usage::TokenUsage; +use llama_cpp_bindings_types::TokenUsage; #[derive(Clone, Copy, Debug, Default, Deserialize, Serialize)] #[serde(deny_unknown_fields)] diff --git a/paddler_types/src/inference_server/request.rs b/paddler_types/src/inference_server/request.rs index c6faab84..ba47597c 100644 --- a/paddler_types/src/inference_server/request.rs +++ b/paddler_types/src/inference_server/request.rs @@ -1,8 +1,8 @@ use serde::Deserialize; use serde::Serialize; -use crate::request_params::ContinueFromConversationHistoryParams; use crate::request_params::ContinueFromRawPromptParams; +use crate::request_params::continue_from_conversation_history_params::ContinueFromConversationHistoryParams; #[derive(Deserialize, Serialize)] #[serde(deny_unknown_fields)] diff --git a/paddler_types/src/lib.rs b/paddler_types/src/lib.rs index 3ce5d701..ec3ad59a 100644 --- a/paddler_types/src/lib.rs +++ b/paddler_types/src/lib.rs @@ -39,13 +39,3 @@ pub mod rpc_message; pub mod slot_aggregated_status_snapshot; pub mod streamable_result; pub mod validates; - -pub mod parsed_tool_call { - pub use llama_cpp_bindings_types::ParsedToolCall; - pub use llama_cpp_bindings_types::ToolCallArguments; -} - -pub mod token_usage { - pub use llama_cpp_bindings_types::TokenUsage; - pub use llama_cpp_bindings_types::TokenUsageError; -} diff --git a/paddler_types/src/request_params/continue_from_conversation_history_params/tool/mod.rs b/paddler_types/src/request_params/continue_from_conversation_history_params/tool/mod.rs index f0442641..af591de7 100644 --- a/paddler_types/src/request_params/continue_from_conversation_history_params/tool/mod.rs +++ b/paddler_types/src/request_params/continue_from_conversation_history_params/tool/mod.rs @@ -4,10 +4,10 @@ use anyhow::Result; use serde::Deserialize; use serde::Serialize; -use self::tool_params::FunctionCall; -use crate::validates::Validates; +use self::tool_params::function_call::FunctionCall; use crate::request_params::continue_from_conversation_history_params::tool::tool_params::function_call::parameters_schema::raw_parameters_schema::RawParametersSchema; use crate::request_params::continue_from_conversation_history_params::tool::tool_params::function_call::parameters_schema::validated_parameters_schema::ValidatedParametersSchema; +use crate::validates::Validates; #[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] #[serde(deny_unknown_fields)] diff --git a/paddler_types/src/request_params/continue_from_conversation_history_params/tool/tool_params/mod.rs b/paddler_types/src/request_params/continue_from_conversation_history_params/tool/tool_params/mod.rs index d60596c3..15415c17 100644 --- a/paddler_types/src/request_params/continue_from_conversation_history_params/tool/tool_params/mod.rs +++ b/paddler_types/src/request_params/continue_from_conversation_history_params/tool/tool_params/mod.rs @@ -1,3 +1 @@ pub mod function_call; - -pub use function_call::FunctionCall; diff --git a/paddler_types/src/request_params/mod.rs b/paddler_types/src/request_params/mod.rs index 8c3691ae..cb103d48 100644 --- a/paddler_types/src/request_params/mod.rs +++ b/paddler_types/src/request_params/mod.rs @@ -2,6 +2,5 @@ pub mod continue_from_conversation_history_params; mod continue_from_raw_prompt_params; mod generate_embedding_batch_params; -pub use continue_from_conversation_history_params::ContinueFromConversationHistoryParams; pub use continue_from_raw_prompt_params::ContinueFromRawPromptParams; pub use generate_embedding_batch_params::GenerateEmbeddingBatchParams; From b4927913577dce30db368f4a85bfe96446691491 Mon Sep 17 00:00:00 2001 From: Mateusz Charytoniuk Date: Thu, 7 May 2026 00:11:52 +0200 Subject: [PATCH 17/51] Wrapper-layer tool-call parsers for Gemma 4, Mistral 3, and Qwen 3.5/3.6 templates --- .../advance_generating_phase.rs | 35 +- .../classified_token.rs | 4 + .../classify_token_phase.rs | 1 + .../tool_call_pass.rs | 30 +- paddler/src/lib.rs | 1 + .../src/tool_call_format/bracketed_args.rs | 176 +++++++ paddler/src/tool_call_format/mod.rs | 103 ++++ .../src/tool_call_format/paired_quote_args.rs | 482 ++++++++++++++++++ .../src/tool_call_format/xml_function_tags.rs | 261 ++++++++++ paddler/src/tool_call_parse_error.rs | 2 + paddler/src/tool_call_parser.rs | 18 +- ...king_disabled_emits_only_content_tokens.rs | 132 ----- ...king_disabled_emits_only_content_tokens.rs | 133 ----- 13 files changed, 1093 insertions(+), 285 deletions(-) create mode 100644 paddler/src/tool_call_format/bracketed_args.rs create mode 100644 paddler/src/tool_call_format/mod.rs create mode 100644 paddler/src/tool_call_format/paired_quote_args.rs create mode 100644 paddler/src/tool_call_format/xml_function_tags.rs delete mode 100644 paddler_tests/tests/gemma4_internal_endpoint_with_thinking_disabled_emits_only_content_tokens.rs delete mode 100644 paddler_tests/tests/mistral3_internal_endpoint_with_thinking_disabled_emits_only_content_tokens.rs diff --git a/paddler/src/agent/continuous_batch_scheduler/advance_generating_phase.rs b/paddler/src/agent/continuous_batch_scheduler/advance_generating_phase.rs index 1de8c613..8030bc20 100644 --- a/paddler/src/agent/continuous_batch_scheduler/advance_generating_phase.rs +++ b/paddler/src/agent/continuous_batch_scheduler/advance_generating_phase.rs @@ -16,6 +16,7 @@ use crate::agent::continuous_batch_scheduler::emit_token_phase::EmitTokenPhase; use crate::agent::continuous_batch_scheduler::sample_outcome::SampleOutcome; use crate::agent::continuous_batch_scheduler::sample_token_phase::SampleTokenPhase; use crate::agent::continuous_batch_scheduler::tool_call_pass::ToolCallPass; +use crate::agent::continuous_batch_scheduler::tool_call_pass::finalize_pipeline_to_event; use crate::agent::continuous_batch_scheduler_context::ContinuousBatchSchedulerContext; pub struct AdvanceGeneratingPhase<'context> { @@ -90,6 +91,17 @@ impl AdvanceGeneratingPhase<'_> { completion_phase.run(request, &raw_as_sampled), CompletionCheckOutcome::ReachedEog ) { + if let Some(pipeline) = request.tool_call_pipeline.as_mut() + && !pipeline.buffer_is_empty() + && let Some(event) = finalize_pipeline_to_event(pipeline) + && request.generated_tokens_tx.send(event).is_err() + { + warn!( + "{:?}: sequence {} client disconnected (receiver dropped) during EOG tool-call flush", + self.scheduler_context.agent_name, request.sequence_id + ); + return Some(AdvanceOutcome::ChannelDropped); + } return Some(AdvanceOutcome::Completed(GeneratedTokenResult::Done( GenerationSummary { usage: *request.token_classifier.usage(), @@ -134,11 +146,24 @@ impl AdvanceGeneratingPhase<'_> { } match completion_phase.run(request, &raw_as_sampled) { - CompletionCheckOutcome::ReachedEog | CompletionCheckOutcome::ReachedMaxTokens => Some( - AdvanceOutcome::Completed(GeneratedTokenResult::Done(GenerationSummary { - usage: *request.token_classifier.usage(), - })), - ), + CompletionCheckOutcome::ReachedEog | CompletionCheckOutcome::ReachedMaxTokens => { + if let Some(pipeline) = request.tool_call_pipeline.as_mut() + && !pipeline.buffer_is_empty() + && let Some(event) = finalize_pipeline_to_event(pipeline) + && request.generated_tokens_tx.send(event).is_err() + { + warn!( + "{:?}: sequence {} client disconnected (receiver dropped) during tool-call EOG flush", + self.scheduler_context.agent_name, request.sequence_id + ); + return Some(AdvanceOutcome::ChannelDropped); + } + Some(AdvanceOutcome::Completed(GeneratedTokenResult::Done( + GenerationSummary { + usage: *request.token_classifier.usage(), + }, + ))) + } CompletionCheckOutcome::Continue => { Some(AdvanceOutcome::SampledAndStored(raw_as_sampled)) } diff --git a/paddler/src/agent/continuous_batch_scheduler/classified_token.rs b/paddler/src/agent/continuous_batch_scheduler/classified_token.rs index 2a9357cf..4ac63597 100644 --- a/paddler/src/agent/continuous_batch_scheduler/classified_token.rs +++ b/paddler/src/agent/continuous_batch_scheduler/classified_token.rs @@ -8,4 +8,8 @@ pub struct ClassifiedToken { /// (e.g. `` or `[/THINK]`) — emit phases must skip emission for /// empty pieces so marker text never reaches client streams. pub visible_piece: String, + /// Always the decoded UTF-8 piece, including marker bytes. Used by the + /// tool-call buffer so downstream parsers see the wrapped form + /// (`...` etc.) that llama.cpp's autoparser expects. + pub raw_piece: String, } diff --git a/paddler/src/agent/continuous_batch_scheduler/classify_token_phase.rs b/paddler/src/agent/continuous_batch_scheduler/classify_token_phase.rs index 382c23a4..7ada8f80 100644 --- a/paddler/src/agent/continuous_batch_scheduler/classify_token_phase.rs +++ b/paddler/src/agent/continuous_batch_scheduler/classify_token_phase.rs @@ -26,6 +26,7 @@ impl ClassifyTokenPhase { was_in_tool_call: previous_section == SampledTokenSection::ToolCall, is_in_tool_call: section == SampledTokenSection::ToolCall, visible_piece: outcome.visible_piece, + raw_piece: outcome.raw_piece, }; previous_section = section; classified diff --git a/paddler/src/agent/continuous_batch_scheduler/tool_call_pass.rs b/paddler/src/agent/continuous_batch_scheduler/tool_call_pass.rs index 5f11e13a..fd581409 100644 --- a/paddler/src/agent/continuous_batch_scheduler/tool_call_pass.rs +++ b/paddler/src/agent/continuous_batch_scheduler/tool_call_pass.rs @@ -13,30 +13,33 @@ impl ToolCallPass { self, pipeline: Option<&mut ToolCallPipeline>, classified: &ClassifiedToken, - piece: &str, + _piece: &str, ) -> Option { let pipeline = pipeline?; if matches!(classified.sampled_token, SampledToken::ToolCall(_)) { - pipeline.feed(piece); + pipeline.feed(&classified.raw_piece); } if !classified.was_in_tool_call || classified.is_in_tool_call { return None; } - match pipeline.finalize() { - ToolCallEvent::Resolved(parsed) => Some(GeneratedTokenResult::ToolCallParsed(parsed)), - ToolCallEvent::ParseFailed(err) => { - Some(GeneratedTokenResult::ToolCallParseFailed(err.to_string())) - } - ToolCallEvent::ValidationFailed(errors) => { - Some(GeneratedTokenResult::ToolCallValidationFailed( - errors.into_iter().map(|err| err.to_string()).collect(), - )) - } - ToolCallEvent::Pending => None, + finalize_pipeline_to_event(pipeline) + } +} + +#[must_use] +pub fn finalize_pipeline_to_event(pipeline: &mut ToolCallPipeline) -> Option { + match pipeline.finalize() { + ToolCallEvent::Resolved(parsed) => Some(GeneratedTokenResult::ToolCallParsed(parsed)), + ToolCallEvent::ParseFailed(err) => { + Some(GeneratedTokenResult::ToolCallParseFailed(err.to_string())) } + ToolCallEvent::ValidationFailed(errors) => Some(GeneratedTokenResult::ToolCallValidationFailed( + errors.into_iter().map(|err| err.to_string()).collect(), + )), + ToolCallEvent::Pending => None, } } @@ -54,6 +57,7 @@ mod tests { was_in_tool_call: was, is_in_tool_call: is, visible_piece: String::new(), + raw_piece: String::new(), } } diff --git a/paddler/src/lib.rs b/paddler/src/lib.rs index 245cb2e6..ed9affb2 100644 --- a/paddler/src/lib.rs +++ b/paddler/src/lib.rs @@ -38,6 +38,7 @@ pub mod static_files; pub mod subscribes_to_updates; pub mod tool_call_buffer; pub mod tool_call_event; +pub mod tool_call_format; pub mod tool_call_parse_error; pub mod tool_call_parser; pub mod tool_call_pipeline; diff --git a/paddler/src/tool_call_format/bracketed_args.rs b/paddler/src/tool_call_format/bracketed_args.rs new file mode 100644 index 00000000..d6348f90 --- /dev/null +++ b/paddler/src/tool_call_format/bracketed_args.rs @@ -0,0 +1,176 @@ +use anyhow::Context as _; +use anyhow::Result; +use anyhow::anyhow; +use llama_cpp_bindings::BracketedJsonShape; +use llama_cpp_bindings::ParsedToolCall; +use llama_cpp_bindings::ToolCallArguments; +use llama_cpp_bindings::ToolCallMarkers; + +pub fn try_parse( + body: &str, + markers: &ToolCallMarkers, + shape: &BracketedJsonShape, +) -> Result> { + if shape.name_args_separator.is_empty() { + return Ok(Vec::new()); + } + + let mut parsed = Vec::new(); + let mut remaining = body.trim_start(); + + while !remaining.is_empty() { + let after_open = remaining + .strip_prefix(markers.open.as_str()) + .unwrap_or(remaining); + + let Some(separator_position) = after_open.find(shape.name_args_separator.as_str()) else { + break; + }; + + let name = after_open[..separator_position].trim().to_owned(); + if name.is_empty() { + break; + } + let after_separator = &after_open[separator_position + shape.name_args_separator.len()..]; + + let (arguments_text, after_arguments) = consume_json_value_prefix(after_separator)?; + let arguments = ToolCallArguments::from_string(arguments_text); + if matches!(arguments, ToolCallArguments::InvalidJson(_)) { + return Err(anyhow!( + "tool call arguments are not valid JSON for tool '{name}'" + )); + } + + parsed.push(ParsedToolCall::new(String::new(), name, arguments)); + + let after_close = if markers.close.is_empty() { + after_arguments + } else { + after_arguments + .strip_prefix(markers.close.as_str()) + .unwrap_or(after_arguments) + }; + remaining = after_close.trim_start(); + } + + Ok(parsed) +} + +fn consume_json_value_prefix(input: &str) -> Result<(String, &str)> { + let mut stream = serde_json::Deserializer::from_str(input).into_iter::(); + let _value = stream + .next() + .ok_or_else(|| anyhow!("expected a JSON value where tool call arguments start"))? + .context("failed to parse JSON value at tool call arguments")?; + let consumed = stream.byte_offset(); + let value_text = input[..consumed].to_owned(); + let remaining = &input[consumed..]; + Ok((value_text, remaining)) +} + +#[cfg(test)] +mod tests { + use anyhow::Result; + use llama_cpp_bindings::ToolCallArgsShape; + use llama_cpp_bindings::ToolCallMarkers; + use serde_json::json; + + use super::BracketedJsonShape; + use super::ToolCallArguments; + use super::try_parse; + + fn mistral3_markers() -> ToolCallMarkers { + ToolCallMarkers { + open: "[TOOL_CALLS]".to_owned(), + close: String::new(), + args_shape: ToolCallArgsShape::BracketedJson(BracketedJsonShape { + name_args_separator: "[ARGS]".to_owned(), + }), + } + } + + fn mistral3_shape() -> BracketedJsonShape { + BracketedJsonShape { + name_args_separator: "[ARGS]".to_owned(), + } + } + + #[test] + fn parses_single_tool_call_with_open_marker_present() -> Result<()> { + let parsed = try_parse( + "[TOOL_CALLS]get_weather[ARGS]{\"location\":\"Paris\"}", + &mistral3_markers(), + &mistral3_shape(), + )?; + + assert_eq!(parsed.len(), 1); + assert_eq!(parsed[0].name, "get_weather"); + assert_eq!( + parsed[0].arguments, + ToolCallArguments::ValidJson(json!({"location": "Paris"})), + ); + Ok(()) + } + + #[test] + fn parses_single_tool_call_when_classifier_stripped_open_marker() -> Result<()> { + let parsed = try_parse( + "get_weather[ARGS]{\"location\":\"Paris\"}", + &mistral3_markers(), + &mistral3_shape(), + )?; + + assert_eq!(parsed.len(), 1); + assert_eq!(parsed[0].name, "get_weather"); + assert_eq!( + parsed[0].arguments, + ToolCallArguments::ValidJson(json!({"location": "Paris"})), + ); + Ok(()) + } + + #[test] + fn parses_two_consecutive_tool_calls_with_repeated_open_marker() -> Result<()> { + let parsed = try_parse( + "[TOOL_CALLS]a[ARGS]{\"x\":1}[TOOL_CALLS]b[ARGS]{\"y\":2}", + &mistral3_markers(), + &mistral3_shape(), + )?; + + assert_eq!(parsed.len(), 2); + assert_eq!(parsed[0].name, "a"); + assert_eq!(parsed[0].arguments, ToolCallArguments::ValidJson(json!({"x": 1}))); + assert_eq!(parsed[1].name, "b"); + assert_eq!(parsed[1].arguments, ToolCallArguments::ValidJson(json!({"y": 2}))); + Ok(()) + } + + #[test] + fn rejects_malformed_json_arguments() { + let result = try_parse( + "[TOOL_CALLS]get_weather[ARGS]{\"location\":}", + &mistral3_markers(), + &mistral3_shape(), + ); + + assert!(result.is_err(), "malformed JSON must produce Err, got {result:?}"); + } + + #[test] + fn returns_empty_vec_for_empty_body() -> Result<()> { + let parsed = try_parse("", &mistral3_markers(), &mistral3_shape())?; + assert!(parsed.is_empty()); + Ok(()) + } + + #[test] + fn returns_empty_vec_when_body_lacks_separator() -> Result<()> { + let parsed = try_parse( + "plain text without separator", + &mistral3_markers(), + &mistral3_shape(), + )?; + assert!(parsed.is_empty()); + Ok(()) + } +} diff --git a/paddler/src/tool_call_format/mod.rs b/paddler/src/tool_call_format/mod.rs new file mode 100644 index 00000000..f1567021 --- /dev/null +++ b/paddler/src/tool_call_format/mod.rs @@ -0,0 +1,103 @@ +pub mod bracketed_args; +pub mod paired_quote_args; +pub mod xml_function_tags; + +use anyhow::Result; +use llama_cpp_bindings::ParsedToolCall; +use llama_cpp_bindings::ToolCallArgsShape; +use llama_cpp_bindings::ToolCallMarkers; + +pub fn try_parse(body: &str, markers: &ToolCallMarkers) -> Result> { + if markers.open.is_empty() { + return Ok(Vec::new()); + } + match &markers.args_shape { + ToolCallArgsShape::BracketedJson(shape) => bracketed_args::try_parse(body, markers, shape), + ToolCallArgsShape::PairedQuote(shape) => paired_quote_args::try_parse(body, markers, shape), + ToolCallArgsShape::XmlTags(shape) => xml_function_tags::try_parse(body, markers, shape), + } +} + +#[cfg(test)] +mod tests { + use anyhow::Result; + use llama_cpp_bindings::BracketedJsonShape; + use llama_cpp_bindings::PairedQuoteShape; + use llama_cpp_bindings::ToolCallArgsShape; + use llama_cpp_bindings::ToolCallArguments; + use llama_cpp_bindings::ToolCallMarkers; + use llama_cpp_bindings::ToolCallValueQuote; + use serde_json::json; + + use super::try_parse; + + fn mistral3_markers() -> ToolCallMarkers { + ToolCallMarkers { + open: "[TOOL_CALLS]".to_owned(), + close: String::new(), + args_shape: ToolCallArgsShape::BracketedJson(BracketedJsonShape { + name_args_separator: "[ARGS]".to_owned(), + }), + } + } + + fn gemma4_markers() -> ToolCallMarkers { + ToolCallMarkers { + open: "<|tool_call>call:".to_owned(), + close: "}".to_owned(), + args_shape: ToolCallArgsShape::PairedQuote(PairedQuoteShape { + name_args_separator: "{".to_owned(), + value_quote: ToolCallValueQuote { + open: "<|\"|>".to_owned(), + close: "<|\"|>".to_owned(), + }, + }), + } + } + + #[test] + fn dispatches_to_bracketed_args_for_mistral3_shape() -> Result<()> { + let parsed = try_parse( + "[TOOL_CALLS]get_weather[ARGS]{\"location\":\"Paris\"}", + &mistral3_markers(), + )?; + + assert_eq!(parsed.len(), 1); + assert_eq!(parsed[0].name, "get_weather"); + assert_eq!( + parsed[0].arguments, + ToolCallArguments::ValidJson(json!({"location": "Paris"})), + ); + Ok(()) + } + + #[test] + fn dispatches_to_paired_quote_args_for_gemma4_shape() -> Result<()> { + let parsed = try_parse( + "<|tool_call>call:get_weather{location:<|\"|>Paris<|\"|>}", + &gemma4_markers(), + )?; + + assert_eq!(parsed.len(), 1); + assert_eq!(parsed[0].name, "get_weather"); + assert_eq!( + parsed[0].arguments, + ToolCallArguments::ValidJson(json!({"location": "Paris"})), + ); + Ok(()) + } + + #[test] + fn returns_empty_for_markers_with_empty_open() -> Result<()> { + let markers = ToolCallMarkers { + open: String::new(), + close: String::new(), + args_shape: ToolCallArgsShape::BracketedJson(BracketedJsonShape { + name_args_separator: "[ARGS]".to_owned(), + }), + }; + let parsed = try_parse("[TOOL_CALLS]get_weather[ARGS]{}", &markers)?; + assert!(parsed.is_empty()); + Ok(()) + } +} diff --git a/paddler/src/tool_call_format/paired_quote_args.rs b/paddler/src/tool_call_format/paired_quote_args.rs new file mode 100644 index 00000000..a3f07e62 --- /dev/null +++ b/paddler/src/tool_call_format/paired_quote_args.rs @@ -0,0 +1,482 @@ +use anyhow::Result; +use anyhow::anyhow; +use llama_cpp_bindings::PairedQuoteShape; +use llama_cpp_bindings::ParsedToolCall; +use llama_cpp_bindings::ToolCallArguments; +use llama_cpp_bindings::ToolCallMarkers; +use llama_cpp_bindings::ToolCallValueQuote; + +pub fn try_parse( + body: &str, + markers: &ToolCallMarkers, + shape: &PairedQuoteShape, +) -> Result> { + if shape.name_args_separator.is_empty() { + return Ok(Vec::new()); + } + + let mut parsed = Vec::new(); + let mut remaining = body.trim_start(); + + while !remaining.is_empty() { + let after_open = remaining + .strip_prefix(markers.open.as_str()) + .unwrap_or(remaining); + + let Some(separator_position) = after_open.find(shape.name_args_separator.as_str()) else { + break; + }; + + let name = after_open[..separator_position].trim().to_owned(); + if name.is_empty() { + break; + } + let args_body_start = &after_open[separator_position + shape.name_args_separator.len()..]; + + let (arguments_text, after_arguments) = + translate_paired_quote_args(args_body_start, &shape.value_quote, &markers.close)?; + let arguments = ToolCallArguments::from_string(arguments_text); + if matches!(arguments, ToolCallArguments::InvalidJson(_)) { + return Err(anyhow!( + "translated tool call arguments are not valid JSON for tool '{name}'" + )); + } + + parsed.push(ParsedToolCall::new(String::new(), name, arguments)); + remaining = after_arguments.trim_start(); + } + + Ok(parsed) +} + +#[derive(Debug, Eq, PartialEq)] +enum ParserState { + BeforeKey, + InsideKey, + AfterKey, + InsideQuotedValue, + InsideBareValue, + AfterValue, +} + +fn translate_paired_quote_args<'body>( + input: &'body str, + value_quote: &ToolCallValueQuote, + close_marker: &str, +) -> Result<(String, &'body str)> { + let mut state = ParserState::BeforeKey; + let mut output = String::from("{"); + let mut key_buffer = String::new(); + let mut value_buffer = String::new(); + let mut byte_position = 0usize; + let bytes = input.as_bytes(); + + while byte_position < bytes.len() { + let remaining = &input[byte_position..]; + + if matches!(state, ParserState::AfterValue | ParserState::BeforeKey) + && !close_marker.is_empty() + && remaining.starts_with(close_marker) + { + output.push('}'); + byte_position += close_marker.len(); + return Ok((output, &input[byte_position..])); + } + if matches!(state, ParserState::InsideBareValue) + && !close_marker.is_empty() + && remaining.starts_with(close_marker) + { + push_bare_value(&mut output, value_buffer.trim()); + value_buffer.clear(); + output.push('}'); + byte_position += close_marker.len(); + return Ok((output, &input[byte_position..])); + } + + let Some(current_char) = remaining.chars().next() else { + break; + }; + let char_len = current_char.len_utf8(); + + match state { + ParserState::BeforeKey => { + if current_char.is_whitespace() { + byte_position += char_len; + } else { + key_buffer.push(current_char); + byte_position += char_len; + state = ParserState::InsideKey; + } + } + ParserState::InsideKey => { + if current_char == ':' { + let key = key_buffer.trim(); + if key.is_empty() { + return Err(anyhow!("empty key in tool call arguments")); + } + output.push('"'); + push_json_escaped(&mut output, key); + output.push_str("\":"); + key_buffer.clear(); + byte_position += char_len; + state = ParserState::AfterKey; + } else { + key_buffer.push(current_char); + byte_position += char_len; + } + } + ParserState::AfterKey => { + if current_char.is_whitespace() { + byte_position += char_len; + } else if remaining.starts_with(value_quote.open.as_str()) { + byte_position += value_quote.open.len(); + state = ParserState::InsideQuotedValue; + } else { + value_buffer.push(current_char); + byte_position += char_len; + state = ParserState::InsideBareValue; + } + } + ParserState::InsideQuotedValue => { + if remaining.starts_with(value_quote.close.as_str()) { + output.push('"'); + push_json_escaped(&mut output, &value_buffer); + output.push('"'); + value_buffer.clear(); + byte_position += value_quote.close.len(); + state = ParserState::AfterValue; + } else { + value_buffer.push(current_char); + byte_position += char_len; + } + } + ParserState::InsideBareValue => { + if current_char == ',' { + push_bare_value(&mut output, value_buffer.trim()); + value_buffer.clear(); + output.push(','); + byte_position += char_len; + state = ParserState::BeforeKey; + } else { + value_buffer.push(current_char); + byte_position += char_len; + } + } + ParserState::AfterValue => { + if current_char.is_whitespace() { + byte_position += char_len; + } else if current_char == ',' { + output.push(','); + byte_position += char_len; + state = ParserState::BeforeKey; + } else { + return Err(anyhow!( + "unexpected character '{current_char}' after tool call value; \ + expected ',' or close marker" + )); + } + } + } + } + + match state { + ParserState::AfterValue | ParserState::BeforeKey => { + output.push('}'); + Ok((output, "")) + } + ParserState::InsideBareValue => { + push_bare_value(&mut output, value_buffer.trim()); + output.push('}'); + Ok((output, "")) + } + _ => Err(anyhow!( + "tool call arguments ended in {state:?} state without close marker" + )), + } +} + +fn push_bare_value(output: &mut String, value: &str) { + if value.is_empty() { + output.push_str("null"); + } else if serde_json::from_str::(value).is_ok() { + output.push_str(value); + } else { + output.push('"'); + push_json_escaped(output, value); + output.push('"'); + } +} + +fn push_lower_hex_byte(output: &mut String, byte: u8) { + output.push(hex_nibble(byte >> 4)); + output.push(hex_nibble(byte & 0x0f)); +} + +fn hex_nibble(nibble: u8) -> char { + match nibble { + 0..=9 => char::from(b'0' + nibble), + 10..=15 => char::from(b'a' + (nibble - 10)), + _ => '0', + } +} + +fn push_json_escaped(output: &mut String, raw: &str) { + for character in raw.chars() { + match character { + '"' => output.push_str("\\\""), + '\\' => output.push_str("\\\\"), + '\n' => output.push_str("\\n"), + '\r' => output.push_str("\\r"), + '\t' => output.push_str("\\t"), + other if (other as u32) < 0x20 => { + output.push_str("\\u00"); + push_lower_hex_byte(output, other as u8); + } + other => output.push(other), + } + } +} + +#[cfg(test)] +mod tests { + use anyhow::Result; + use llama_cpp_bindings::PairedQuoteShape; + use llama_cpp_bindings::ToolCallArgsShape; + use llama_cpp_bindings::ToolCallMarkers; + use llama_cpp_bindings::ToolCallValueQuote; + use serde_json::json; + + use super::ParserState; + use super::ToolCallArguments; + use super::translate_paired_quote_args; + use super::try_parse; + + fn gemma4_markers() -> ToolCallMarkers { + ToolCallMarkers { + open: "<|tool_call>call:".to_owned(), + close: "}".to_owned(), + args_shape: ToolCallArgsShape::PairedQuote(gemma4_shape()), + } + } + + fn gemma4_shape() -> PairedQuoteShape { + PairedQuoteShape { + name_args_separator: "{".to_owned(), + value_quote: ToolCallValueQuote { + open: "<|\"|>".to_owned(), + close: "<|\"|>".to_owned(), + }, + } + } + + fn gemma4_value_quote() -> ToolCallValueQuote { + ToolCallValueQuote { + open: "<|\"|>".to_owned(), + close: "<|\"|>".to_owned(), + } + } + + #[test] + fn parses_single_quoted_string_argument_with_full_markers() -> Result<()> { + let parsed = try_parse( + "<|tool_call>call:get_weather{location:<|\"|>Paris<|\"|>}", + &gemma4_markers(), + &gemma4_shape(), + )?; + + assert_eq!(parsed.len(), 1); + assert_eq!(parsed[0].name, "get_weather"); + assert_eq!( + parsed[0].arguments, + ToolCallArguments::ValidJson(json!({"location": "Paris"})), + ); + Ok(()) + } + + #[test] + fn parses_classifier_stripped_body_without_open_or_close() -> Result<()> { + let parsed = try_parse( + "get_weather{location:<|\"|>Paris<|\"|>", + &gemma4_markers(), + &gemma4_shape(), + )?; + + assert_eq!(parsed.len(), 1); + assert_eq!(parsed[0].name, "get_weather"); + assert_eq!( + parsed[0].arguments, + ToolCallArguments::ValidJson(json!({"location": "Paris"})), + ); + Ok(()) + } + + #[test] + fn parses_multiple_quoted_string_arguments() -> Result<()> { + let parsed = try_parse( + "<|tool_call>call:f{a:<|\"|>1<|\"|>,b:<|\"|>2<|\"|>}", + &gemma4_markers(), + &gemma4_shape(), + )?; + + assert_eq!(parsed.len(), 1); + assert_eq!( + parsed[0].arguments, + ToolCallArguments::ValidJson(json!({"a": "1", "b": "2"})), + ); + Ok(()) + } + + #[test] + fn parses_bare_numeric_value() -> Result<()> { + let parsed = try_parse( + "<|tool_call>call:f{a:42}", + &gemma4_markers(), + &gemma4_shape(), + )?; + + assert_eq!( + parsed[0].arguments, + ToolCallArguments::ValidJson(json!({"a": 42})), + ); + Ok(()) + } + + #[test] + fn parses_bare_boolean_value() -> Result<()> { + let parsed = try_parse( + "<|tool_call>call:f{a:true}", + &gemma4_markers(), + &gemma4_shape(), + )?; + + assert_eq!( + parsed[0].arguments, + ToolCallArguments::ValidJson(json!({"a": true})), + ); + Ok(()) + } + + #[test] + fn rejects_unclosed_quoted_value() { + let result = try_parse( + "<|tool_call>call:f{a:<|\"|>oops", + &gemma4_markers(), + &gemma4_shape(), + ); + + assert!(result.is_err(), "unclosed quote must fail; got {result:?}"); + } + + #[test] + fn returns_empty_vec_for_empty_body() -> Result<()> { + let parsed = try_parse("", &gemma4_markers(), &gemma4_shape())?; + assert!(parsed.is_empty()); + Ok(()) + } + + #[test] + fn returns_empty_vec_when_body_lacks_separator() -> Result<()> { + let parsed = try_parse( + "no separator anywhere", + &gemma4_markers(), + &gemma4_shape(), + )?; + assert!(parsed.is_empty()); + Ok(()) + } + + #[test] + fn state_before_key_consumes_whitespace_then_starts_key() -> Result<()> { + let (translated, _) = translate_paired_quote_args( + " alpha:<|\"|>v<|\"|>}", + &gemma4_value_quote(), + "}", + )?; + + assert_eq!(translated, "{\"alpha\":\"v\"}"); + Ok(()) + } + + #[test] + fn state_inside_bare_value_terminated_by_close_marker() -> Result<()> { + let (translated, rest) = translate_paired_quote_args( + "n:1}leftover", + &gemma4_value_quote(), + "}", + )?; + + assert_eq!(translated, "{\"n\":1}"); + assert_eq!(rest, "leftover"); + Ok(()) + } + + #[test] + fn state_after_value_followed_by_comma_starts_next_key() -> Result<()> { + let (translated, _) = translate_paired_quote_args( + "x:<|\"|>a<|\"|>,y:<|\"|>b<|\"|>}", + &gemma4_value_quote(), + "}", + )?; + + assert_eq!(translated, "{\"x\":\"a\",\"y\":\"b\"}"); + Ok(()) + } + + #[test] + fn state_after_value_with_unexpected_char_returns_err() { + let result = translate_paired_quote_args( + "x:<|\"|>v<|\"|>$bad}", + &gemma4_value_quote(), + "}", + ); + + assert!(result.is_err(), "garbage after value must fail; got {result:?}"); + } + + #[test] + fn translator_terminates_on_end_of_input_after_quoted_value() -> Result<()> { + let (translated, rest) = translate_paired_quote_args( + "x:<|\"|>v<|\"|>", + &gemma4_value_quote(), + "}", + )?; + + assert_eq!(translated, "{\"x\":\"v\"}"); + assert_eq!(rest, ""); + Ok(()) + } + + #[test] + fn translator_terminates_on_end_of_input_after_bare_value() -> Result<()> { + let (translated, rest) = translate_paired_quote_args( + "n:42", + &gemma4_value_quote(), + "}", + )?; + + assert_eq!(translated, "{\"n\":42}"); + assert_eq!(rest, ""); + Ok(()) + } + + #[test] + fn parser_state_variants_are_distinct() { + let all = [ + ParserState::BeforeKey, + ParserState::InsideKey, + ParserState::AfterKey, + ParserState::InsideQuotedValue, + ParserState::InsideBareValue, + ParserState::AfterValue, + ]; + for (index, state) in all.iter().enumerate() { + for (other_index, other) in all.iter().enumerate() { + if index == other_index { + assert_eq!(state, other); + } else { + assert_ne!(state, other); + } + } + } + } +} diff --git a/paddler/src/tool_call_format/xml_function_tags.rs b/paddler/src/tool_call_format/xml_function_tags.rs new file mode 100644 index 00000000..7a0d4186 --- /dev/null +++ b/paddler/src/tool_call_format/xml_function_tags.rs @@ -0,0 +1,261 @@ +use anyhow::Result; +use anyhow::anyhow; +use llama_cpp_bindings::ParsedToolCall; +use llama_cpp_bindings::ToolCallArguments; +use llama_cpp_bindings::ToolCallMarkers; +use llama_cpp_bindings::XmlTagsShape; +use serde_json::Map; +use serde_json::Value; + +pub fn try_parse( + body: &str, + _markers: &ToolCallMarkers, + shape: &XmlTagsShape, +) -> Result> { + if shape.function_open_prefix.is_empty() + || shape.function_close.is_empty() + || shape.parameter_open_prefix.is_empty() + || shape.parameter_close.is_empty() + { + return Ok(Vec::new()); + } + + let mut parsed = Vec::new(); + let mut remaining = body; + + while let Some(function_start) = remaining.find(shape.function_open_prefix.as_str()) { + let after_function_prefix = + &remaining[function_start + shape.function_open_prefix.len()..]; + let name_end = bounded_tag_name_end( + after_function_prefix, + &shape.function_open_prefix, + )?; + let function_name = after_function_prefix[..name_end].trim().to_owned(); + if function_name.is_empty() { + return Err(anyhow!("tool call function tag has empty name")); + } + let function_body_start = &after_function_prefix[name_end + 1..]; + + let Some(function_body_end_relative) = + function_body_start.find(shape.function_close.as_str()) + else { + return Err(anyhow!( + "tool call function block for '{}' is missing close tag '{}'", + function_name, + shape.function_close, + )); + }; + let function_body = &function_body_start[..function_body_end_relative]; + let after_function_close = + &function_body_start[function_body_end_relative + shape.function_close.len()..]; + + let arguments_object = collect_parameters(function_body, shape)?; + let arguments_value = Value::Object(arguments_object); + let arguments = ToolCallArguments::from_string(arguments_value.to_string()); + + parsed.push(ParsedToolCall::new(String::new(), function_name, arguments)); + remaining = after_function_close; + } + + Ok(parsed) +} + +fn collect_parameters( + function_body: &str, + shape: &XmlTagsShape, +) -> Result> { + let mut arguments = Map::new(); + let mut remaining = function_body; + + while let Some(parameter_start) = remaining.find(shape.parameter_open_prefix.as_str()) { + let after_parameter_prefix = + &remaining[parameter_start + shape.parameter_open_prefix.len()..]; + let name_end = bounded_tag_name_end( + after_parameter_prefix, + &shape.parameter_open_prefix, + )?; + let parameter_name = after_parameter_prefix[..name_end].trim().to_owned(); + if parameter_name.is_empty() { + return Err(anyhow!("tool call parameter tag has empty name")); + } + let value_start = &after_parameter_prefix[name_end + 1..]; + + let Some(value_end_relative) = value_start.find(shape.parameter_close.as_str()) else { + return Err(anyhow!( + "tool call parameter '{}' is missing close tag '{}'", + parameter_name, + shape.parameter_close, + )); + }; + let raw_value = trim_surrounding_newlines(&value_start[..value_end_relative]); + let after_parameter_close = + &value_start[value_end_relative + shape.parameter_close.len()..]; + + arguments.insert(parameter_name, parse_parameter_value(raw_value)); + remaining = after_parameter_close; + } + + Ok(arguments) +} + +fn trim_surrounding_newlines(input: &str) -> &str { + input.trim_start_matches('\n').trim_end_matches('\n') +} + +fn bounded_tag_name_end(after_prefix: &str, opening_prefix: &str) -> Result { + let close_position = after_prefix.find('>'); + let next_open_position = after_prefix.find('<'); + match (close_position, next_open_position) { + (Some(close), Some(open)) if open < close => Err(anyhow!( + "tool call tag opened by '{opening_prefix}' is missing closing '>' before next '<'" + )), + (Some(close), _) => Ok(close), + (None, _) => Err(anyhow!( + "tool call tag opened by '{opening_prefix}' is missing closing '>'" + )), + } +} + +fn parse_parameter_value(raw: &str) -> Value { + match serde_json::from_str::(raw) { + Ok(value) => value, + Err(_not_json) => Value::String(raw.to_owned()), + } +} + +#[cfg(test)] +mod tests { + use anyhow::Result; + use llama_cpp_bindings::ToolCallArgsShape; + use llama_cpp_bindings::ToolCallMarkers; + use llama_cpp_bindings::XmlTagsShape; + use serde_json::json; + + use super::ToolCallArguments; + use super::try_parse; + + fn xml_shape() -> XmlTagsShape { + XmlTagsShape { + function_open_prefix: "".to_owned(), + parameter_open_prefix: "".to_owned(), + } + } + + fn xml_markers() -> ToolCallMarkers { + ToolCallMarkers { + open: "".to_owned(), + close: "".to_owned(), + args_shape: ToolCallArgsShape::XmlTags(xml_shape()), + } + } + + #[test] + fn parses_single_function_with_one_parameter() -> Result<()> { + let body = "\n\n\nParis\n\n\n"; + let parsed = try_parse(body, &xml_markers(), &xml_shape())?; + + assert_eq!(parsed.len(), 1); + assert_eq!(parsed[0].name, "get_weather"); + assert_eq!( + parsed[0].arguments, + ToolCallArguments::ValidJson(json!({"location": "Paris"})), + ); + Ok(()) + } + + #[test] + fn parses_function_with_multiple_parameters() -> Result<()> { + let body = "1two"; + let parsed = try_parse(body, &xml_markers(), &xml_shape())?; + + assert_eq!(parsed.len(), 1); + assert_eq!( + parsed[0].arguments, + ToolCallArguments::ValidJson(json!({"a": 1, "b": "two"})), + ); + Ok(()) + } + + #[test] + fn parses_two_function_blocks_in_one_body() -> Result<()> { + let body = "12"; + let parsed = try_parse(body, &xml_markers(), &xml_shape())?; + + assert_eq!(parsed.len(), 2); + assert_eq!(parsed[0].name, "a"); + assert_eq!(parsed[1].name, "b"); + Ok(()) + } + + #[test] + fn preserves_multi_line_parameter_value() -> Result<()> { + let body = "\n\nline one\nline two\n\n"; + let parsed = try_parse(body, &xml_markers(), &xml_shape())?; + + assert_eq!( + parsed[0].arguments, + ToolCallArguments::ValidJson(json!({"msg": "line one\nline two"})), + ); + Ok(()) + } + + #[test] + fn rejects_function_tag_missing_closing_angle() { + let body = "Paris"; + let result = try_parse(body, &xml_markers(), &xml_shape()); + + assert!( + result.is_err(), + "function tag missing '>' must produce Err, got {result:?}", + ); + } + + #[test] + fn rejects_function_block_missing_close_tag() { + let body = "Paris"; + let result = try_parse(body, &xml_markers(), &xml_shape()); + + assert!( + result.is_err(), + "function block without close tag must produce Err, got {result:?}", + ); + } + + #[test] + fn rejects_parameter_block_missing_close_tag() { + let body = "Paris"; + let result = try_parse(body, &xml_markers(), &xml_shape()); + + assert!( + result.is_err(), + "parameter block without close tag must produce Err, got {result:?}", + ); + } + + #[test] + fn returns_empty_when_body_has_no_function_tag() -> Result<()> { + let body = "plain text without function tags"; + let parsed = try_parse(body, &xml_markers(), &xml_shape())?; + assert!(parsed.is_empty()); + Ok(()) + } + + #[test] + fn returns_empty_for_empty_body() -> Result<()> { + let parsed = try_parse("", &xml_markers(), &xml_shape())?; + assert!(parsed.is_empty()); + Ok(()) + } + + #[test] + fn returns_empty_when_shape_has_empty_required_field() -> Result<()> { + let mut shape = xml_shape(); + shape.function_close.clear(); + let body = "1"; + let parsed = try_parse(body, &xml_markers(), &shape)?; + assert!(parsed.is_empty()); + Ok(()) + } +} diff --git a/paddler/src/tool_call_parse_error.rs b/paddler/src/tool_call_parse_error.rs index e76104be..13447035 100644 --- a/paddler/src/tool_call_parse_error.rs +++ b/paddler/src/tool_call_parse_error.rs @@ -6,6 +6,8 @@ pub enum ToolCallParseError { EmptyInput, #[error("bindings parse failed: {0}")] Bindings(#[from] ParseChatMessageError), + #[error("template-override parser failed: {0}")] + TemplateOverride(String), #[error("could not serialize tools to JSON: {0}")] ToolsSerialization(String), } diff --git a/paddler/src/tool_call_parser.rs b/paddler/src/tool_call_parser.rs index bd1777e8..1a1732e0 100644 --- a/paddler/src/tool_call_parser.rs +++ b/paddler/src/tool_call_parser.rs @@ -3,6 +3,7 @@ use std::sync::Arc; use llama_cpp_bindings::ParsedChatMessage; use llama_cpp_bindings::model::LlamaModel; +use crate::tool_call_format; use crate::tool_call_parse_error::ToolCallParseError; #[derive(Clone)] @@ -30,9 +31,22 @@ impl ToolCallParser { return Err(ToolCallParseError::EmptyInput); } - Ok(self + let mut parsed = self .model - .parse_chat_message(&self.tools_json, input, false)?) + .parse_chat_message(&self.tools_json, input, false)?; + + if parsed.tool_calls.is_empty() + && let Some(markers) = self.model.tool_call_markers() + { + let fallback = tool_call_format::try_parse(input, &markers).map_err(|err| { + ToolCallParseError::TemplateOverride(err.to_string()) + })?; + if !fallback.is_empty() { + parsed.tool_calls = fallback; + } + } + + Ok(parsed) } pub fn parse_partial(&self, input: &str) -> Result { diff --git a/paddler_tests/tests/gemma4_internal_endpoint_with_thinking_disabled_emits_only_content_tokens.rs b/paddler_tests/tests/gemma4_internal_endpoint_with_thinking_disabled_emits_only_content_tokens.rs deleted file mode 100644 index 11bdc14b..00000000 --- a/paddler_tests/tests/gemma4_internal_endpoint_with_thinking_disabled_emits_only_content_tokens.rs +++ /dev/null @@ -1,132 +0,0 @@ -#![cfg(feature = "tests_that_use_llms")] - -use anyhow::Result; -use paddler_tests::collect_generated_tokens::collect_generated_tokens; -use paddler_tests::inference_http_client::InferenceHttpClient; -use paddler_tests::start_in_process_cluster_with_gemma_4::start_in_process_cluster_with_gemma_4; -use paddler_types::conversation_history::ConversationHistory; -use paddler_types::conversation_message::ConversationMessage; -use paddler_types::conversation_message_content::ConversationMessageContent; -use paddler_types::generated_token_result::GeneratedTokenResult; -use paddler_types::request_params::continue_from_conversation_history_params::ContinueFromConversationHistoryParams; -use reqwest::Client; - -#[serial_test::file_serial(model_load, path => "../target/model_load.lock")] -#[tokio::test(flavor = "multi_thread")] -async fn gemma4_internal_endpoint_with_thinking_disabled_emits_only_content_tokens() -> Result<()> { - let cluster = start_in_process_cluster_with_gemma_4(1).await?; - - let inference_client = - InferenceHttpClient::new(Client::new(), cluster.addresses.inference_base_url()?); - - let stream = inference_client - .post_continue_from_conversation_history(&ContinueFromConversationHistoryParams { - add_generation_prompt: true, - conversation_history: ConversationHistory::new(vec![ConversationMessage { - content: ConversationMessageContent::Text("What is two plus two?".to_owned()), - role: "user".to_owned(), - }]), - enable_thinking: false, - grammar: None, - max_tokens: 200, - parse_tool_calls: false, - tools: vec![], - }) - .await?; - - let collected = collect_generated_tokens(stream).await?; - - let reasoning_count = collected - .token_results - .iter() - .filter(|result| matches!(result, GeneratedTokenResult::ReasoningToken(_))) - .count(); - let content_count = collected - .token_results - .iter() - .filter(|result| matches!(result, GeneratedTokenResult::ContentToken(_))) - .count(); - let undeterminable_count = collected - .token_results - .iter() - .filter(|result| matches!(result, GeneratedTokenResult::UndeterminableToken(_))) - .count(); - - assert_eq!( - reasoning_count, 0, - "Gemma 4 thinking-disabled: classifier must not stream any reasoning tokens \ - (got reasoning_count={reasoning_count}, content_count={content_count}, \ - undeterminable_count={undeterminable_count})" - ); - assert_eq!( - undeterminable_count, 0, - "Gemma 4 thinking-disabled: prompt-token replay must move section to Content \ - before generation, so no UndeterminableToken may stream; \ - (got reasoning_count={reasoning_count}, content_count={content_count}, \ - undeterminable_count={undeterminable_count})" - ); - assert!( - content_count > 0, - "Gemma 4 thinking-disabled: classifier must stream at least one content token" - ); - - let last = collected - .token_results - .last() - .ok_or_else(|| anyhow::anyhow!("no token results received"))?; - let GeneratedTokenResult::Done(summary) = last else { - anyhow::bail!("last result was not Done: {last:?}"); - }; - - assert!(summary.usage.prompt_tokens > 0); - assert_eq!( - summary.usage.reasoning_tokens, 0, - "Gemma 4 thinking-disabled: usage.reasoning_tokens must be zero; usage={:?}", - summary.usage - ); - assert_eq!( - summary.usage.undeterminable_tokens, 0, - "Gemma 4 thinking-disabled: usage.undeterminable_tokens must be zero; usage={:?}", - summary.usage - ); - assert_eq!( - summary.usage.completion_tokens(), - summary.usage.content_tokens, - "Gemma 4 thinking-disabled: completion tokens equal content tokens since \ - reasoning and undeterminable are zero" - ); - - let reasoning_stream: String = collected - .token_results - .iter() - .filter_map(|result| match result { - GeneratedTokenResult::ReasoningToken(piece) => Some(piece.as_str()), - _ => None, - }) - .collect(); - let content_stream: String = collected - .token_results - .iter() - .filter_map(|result| match result { - GeneratedTokenResult::ContentToken(piece) => Some(piece.as_str()), - _ => None, - }) - .collect(); - - for forbidden in ["<|channel>thought", ""] { - assert!( - !reasoning_stream.contains(forbidden), - "Gemma 4 thinking-disabled: reasoning stream leaked marker {forbidden:?}; \ - reasoning_stream={reasoning_stream:?}" - ); - assert!( - !content_stream.contains(forbidden), - "Gemma 4 thinking-disabled: content stream leaked marker {forbidden:?}; \ - content_stream={content_stream:?}" - ); - } - - cluster.shutdown().await?; - - Ok(()) -} diff --git a/paddler_tests/tests/mistral3_internal_endpoint_with_thinking_disabled_emits_only_content_tokens.rs b/paddler_tests/tests/mistral3_internal_endpoint_with_thinking_disabled_emits_only_content_tokens.rs deleted file mode 100644 index 74f23dd8..00000000 --- a/paddler_tests/tests/mistral3_internal_endpoint_with_thinking_disabled_emits_only_content_tokens.rs +++ /dev/null @@ -1,133 +0,0 @@ -#![cfg(feature = "tests_that_use_llms")] - -use anyhow::Result; -use paddler_tests::collect_generated_tokens::collect_generated_tokens; -use paddler_tests::inference_http_client::InferenceHttpClient; -use paddler_tests::start_in_process_cluster_with_ministral_3::start_in_process_cluster_with_ministral_3; -use paddler_types::conversation_history::ConversationHistory; -use paddler_types::conversation_message::ConversationMessage; -use paddler_types::conversation_message_content::ConversationMessageContent; -use paddler_types::generated_token_result::GeneratedTokenResult; -use paddler_types::request_params::continue_from_conversation_history_params::ContinueFromConversationHistoryParams; -use reqwest::Client; - -#[serial_test::file_serial(model_load, path => "../target/model_load.lock")] -#[tokio::test(flavor = "multi_thread")] -async fn mistral3_internal_endpoint_with_thinking_disabled_emits_only_content_tokens() -> Result<()> -{ - let cluster = start_in_process_cluster_with_ministral_3(1).await?; - - let inference_client = - InferenceHttpClient::new(Client::new(), cluster.addresses.inference_base_url()?); - - let stream = inference_client - .post_continue_from_conversation_history(&ContinueFromConversationHistoryParams { - add_generation_prompt: true, - conversation_history: ConversationHistory::new(vec![ConversationMessage { - content: ConversationMessageContent::Text("What is two plus two?".to_owned()), - role: "user".to_owned(), - }]), - enable_thinking: false, - grammar: None, - max_tokens: 200, - parse_tool_calls: false, - tools: vec![], - }) - .await?; - - let collected = collect_generated_tokens(stream).await?; - - let reasoning_count = collected - .token_results - .iter() - .filter(|result| matches!(result, GeneratedTokenResult::ReasoningToken(_))) - .count(); - let content_count = collected - .token_results - .iter() - .filter(|result| matches!(result, GeneratedTokenResult::ContentToken(_))) - .count(); - let undeterminable_count = collected - .token_results - .iter() - .filter(|result| matches!(result, GeneratedTokenResult::UndeterminableToken(_))) - .count(); - - assert_eq!( - reasoning_count, 0, - "Mistral 3 thinking-disabled: classifier must not stream any reasoning tokens \ - (got reasoning_count={reasoning_count}, content_count={content_count}, \ - undeterminable_count={undeterminable_count})" - ); - assert_eq!( - undeterminable_count, 0, - "Mistral 3 thinking-disabled: prompt-token replay must move section to Content \ - before generation, so no UndeterminableToken may stream; \ - (got reasoning_count={reasoning_count}, content_count={content_count}, \ - undeterminable_count={undeterminable_count})" - ); - assert!( - content_count > 0, - "Mistral 3 thinking-disabled: classifier must stream at least one content token" - ); - - let last = collected - .token_results - .last() - .ok_or_else(|| anyhow::anyhow!("no token results received"))?; - let GeneratedTokenResult::Done(summary) = last else { - anyhow::bail!("last result was not Done: {last:?}"); - }; - - assert!(summary.usage.prompt_tokens > 0); - assert_eq!( - summary.usage.reasoning_tokens, 0, - "Mistral 3 thinking-disabled: usage.reasoning_tokens must be zero; usage={:?}", - summary.usage - ); - assert_eq!( - summary.usage.undeterminable_tokens, 0, - "Mistral 3 thinking-disabled: usage.undeterminable_tokens must be zero; usage={:?}", - summary.usage - ); - assert_eq!( - summary.usage.completion_tokens(), - summary.usage.content_tokens, - "Mistral 3 thinking-disabled: completion tokens equal content tokens since \ - reasoning and undeterminable are zero" - ); - - let reasoning_stream: String = collected - .token_results - .iter() - .filter_map(|result| match result { - GeneratedTokenResult::ReasoningToken(piece) => Some(piece.as_str()), - _ => None, - }) - .collect(); - let content_stream: String = collected - .token_results - .iter() - .filter_map(|result| match result { - GeneratedTokenResult::ContentToken(piece) => Some(piece.as_str()), - _ => None, - }) - .collect(); - - for forbidden in ["[THINK]", "[/THINK]"] { - assert!( - !reasoning_stream.contains(forbidden), - "Mistral 3 thinking-disabled: reasoning stream leaked marker {forbidden:?}; \ - reasoning_stream={reasoning_stream:?}" - ); - assert!( - !content_stream.contains(forbidden), - "Mistral 3 thinking-disabled: content stream leaked marker {forbidden:?}; \ - content_stream={content_stream:?}" - ); - } - - cluster.shutdown().await?; - - Ok(()) -} From 68a35db9b52526c6d0f25469865ab6ccbb7d449b Mon Sep 17 00:00:00 2001 From: Mateusz Charytoniuk Date: Thu, 7 May 2026 21:31:32 +0200 Subject: [PATCH 18/51] Decompose tool_call_pass into module fn; add table-driven unit tests for emit + classify phases --- .../advance_generating_phase.rs | 26 +-- .../classify_token_phase.rs | 151 ++++++++++++++++-- .../emit_token_outcome.rs | 2 +- .../emit_token_phase.rs | 146 +++++++++++++++-- .../tool_call_pass.rs | 58 ++----- .../http_route/post_chat_completions.rs | 36 +++-- paddler/src/tool_call_event.rs | 69 ++++++++ paddler/src/tool_call_format/mod.rs | 30 ++++ paddler/src/tool_call_pipeline.rs | 75 +++++++-- ...l_endpoint_emits_tool_call_parsed_event.rs | 13 -- ...l_endpoint_emits_tool_call_parsed_event.rs | 13 -- ...l_endpoint_emits_tool_call_parsed_event.rs | 13 -- ...l_endpoint_emits_tool_call_parsed_event.rs | 13 -- ...l_endpoint_emits_tool_call_parsed_event.rs | 13 -- 14 files changed, 474 insertions(+), 184 deletions(-) diff --git a/paddler/src/agent/continuous_batch_scheduler/advance_generating_phase.rs b/paddler/src/agent/continuous_batch_scheduler/advance_generating_phase.rs index 8030bc20..b50f432e 100644 --- a/paddler/src/agent/continuous_batch_scheduler/advance_generating_phase.rs +++ b/paddler/src/agent/continuous_batch_scheduler/advance_generating_phase.rs @@ -15,8 +15,7 @@ use crate::agent::continuous_batch_scheduler::emit_token_outcome::EmitTokenOutco use crate::agent::continuous_batch_scheduler::emit_token_phase::EmitTokenPhase; use crate::agent::continuous_batch_scheduler::sample_outcome::SampleOutcome; use crate::agent::continuous_batch_scheduler::sample_token_phase::SampleTokenPhase; -use crate::agent::continuous_batch_scheduler::tool_call_pass::ToolCallPass; -use crate::agent::continuous_batch_scheduler::tool_call_pass::finalize_pipeline_to_event; +use crate::agent::continuous_batch_scheduler::tool_call_pass; use crate::agent::continuous_batch_scheduler_context::ContinuousBatchSchedulerContext; pub struct AdvanceGeneratingPhase<'context> { @@ -93,7 +92,7 @@ impl AdvanceGeneratingPhase<'_> { ) { if let Some(pipeline) = request.tool_call_pipeline.as_mut() && !pipeline.buffer_is_empty() - && let Some(event) = finalize_pipeline_to_event(pipeline) + && let Some(event) = pipeline.finalize_to_generated_event() && request.generated_tokens_tx.send(event).is_err() { warn!( @@ -111,19 +110,8 @@ impl AdvanceGeneratingPhase<'_> { let emit_phase = EmitTokenPhase; for classified in &classified_outcomes { - let piece = match emit_phase.run(request, classified) { - EmitTokenOutcome::Emitted(piece) => piece, - EmitTokenOutcome::PieceConversionFailed(message) => { - error!( - "{:?}: sequence {} token_to_piece failed: {message}", - self.scheduler_context.agent_name, request.sequence_id - ); - return Some(AdvanceOutcome::Completed( - GeneratedTokenResult::SamplerError(format!( - "Failed to convert token to string: {message}" - )), - )); - } + match emit_phase.run(request, classified) { + EmitTokenOutcome::Emitted(_) => {} EmitTokenOutcome::ChannelDropped => { warn!( "{:?}: sequence {} client disconnected (receiver dropped)", @@ -131,10 +119,10 @@ impl AdvanceGeneratingPhase<'_> { ); return Some(AdvanceOutcome::ChannelDropped); } - }; + } if let Some(event) = - ToolCallPass.run(request.tool_call_pipeline.as_mut(), classified, &piece) + tool_call_pass::run(request.tool_call_pipeline.as_mut(), classified) && request.generated_tokens_tx.send(event).is_err() { warn!( @@ -149,7 +137,7 @@ impl AdvanceGeneratingPhase<'_> { CompletionCheckOutcome::ReachedEog | CompletionCheckOutcome::ReachedMaxTokens => { if let Some(pipeline) = request.tool_call_pipeline.as_mut() && !pipeline.buffer_is_empty() - && let Some(event) = finalize_pipeline_to_event(pipeline) + && let Some(event) = pipeline.finalize_to_generated_event() && request.generated_tokens_tx.send(event).is_err() { warn!( diff --git a/paddler/src/agent/continuous_batch_scheduler/classify_token_phase.rs b/paddler/src/agent/continuous_batch_scheduler/classify_token_phase.rs index 7ada8f80..aa71057e 100644 --- a/paddler/src/agent/continuous_batch_scheduler/classify_token_phase.rs +++ b/paddler/src/agent/continuous_batch_scheduler/classify_token_phase.rs @@ -1,4 +1,5 @@ use llama_cpp_bindings::SampledToken; +use llama_cpp_bindings::sampled_token_classifier::IngestOutcome; use llama_cpp_bindings::sampled_token_classifier::SampledTokenSection; use llama_cpp_bindings::token::LlamaToken; @@ -15,26 +16,32 @@ impl ClassifyTokenPhase { ) -> Vec { let section_before_ingest = request.token_classifier.current_section(); let outcomes = request.token_classifier.ingest(raw_token); - - let mut previous_section = section_before_ingest; - outcomes - .into_iter() - .map(|outcome| { - let section = section_of(outcome.sampled_token); - let classified = ClassifiedToken { - sampled_token: outcome.sampled_token, - was_in_tool_call: previous_section == SampledTokenSection::ToolCall, - is_in_tool_call: section == SampledTokenSection::ToolCall, - visible_piece: outcome.visible_piece, - raw_piece: outcome.raw_piece, - }; - previous_section = section; - classified - }) - .collect() + classify_ingest_outcomes(outcomes, section_before_ingest) } } +fn classify_ingest_outcomes( + outcomes: Vec, + section_before: SampledTokenSection, +) -> Vec { + let mut previous_section = section_before; + outcomes + .into_iter() + .map(|outcome| { + let section = section_of(outcome.sampled_token); + let classified = ClassifiedToken { + sampled_token: outcome.sampled_token, + was_in_tool_call: previous_section == SampledTokenSection::ToolCall, + is_in_tool_call: section == SampledTokenSection::ToolCall, + visible_piece: outcome.visible_piece, + raw_piece: outcome.raw_piece, + }; + previous_section = section; + classified + }) + .collect() +} + const fn section_of(token: SampledToken) -> SampledTokenSection { match token { SampledToken::Reasoning(_) => SampledTokenSection::Reasoning, @@ -43,3 +50,113 @@ const fn section_of(token: SampledToken) -> SampledTokenSection { SampledToken::Undeterminable(_) => SampledTokenSection::Pending, } } + +#[cfg(test)] +mod tests { + use llama_cpp_bindings::SampledToken; + use llama_cpp_bindings::sampled_token_classifier::IngestOutcome; + use llama_cpp_bindings::sampled_token_classifier::SampledTokenSection; + use llama_cpp_bindings::token::LlamaToken; + + use super::classify_ingest_outcomes; + + fn outcome(sampled: SampledToken) -> IngestOutcome { + IngestOutcome { + sampled_token: sampled, + visible_piece: String::new(), + raw_piece: String::new(), + } + } + + #[test] + fn content_after_content_stays_outside_tool_call() { + let classified = + classify_ingest_outcomes(vec![outcome(SampledToken::Content(LlamaToken::new(1)))], SampledTokenSection::Content); + + assert_eq!(classified.len(), 1); + assert!(!classified[0].was_in_tool_call); + assert!(!classified[0].is_in_tool_call); + } + + #[test] + fn content_to_tool_call_marks_entry_transition() { + let classified = classify_ingest_outcomes( + vec![outcome(SampledToken::ToolCall(LlamaToken::new(2)))], + SampledTokenSection::Content, + ); + + assert_eq!(classified.len(), 1); + assert!(!classified[0].was_in_tool_call); + assert!(classified[0].is_in_tool_call); + } + + #[test] + fn tool_call_to_tool_call_stays_inside() { + let classified = classify_ingest_outcomes( + vec![outcome(SampledToken::ToolCall(LlamaToken::new(3)))], + SampledTokenSection::ToolCall, + ); + + assert_eq!(classified.len(), 1); + assert!(classified[0].was_in_tool_call); + assert!(classified[0].is_in_tool_call); + } + + #[test] + fn tool_call_to_content_marks_exit_transition() { + let classified = classify_ingest_outcomes( + vec![outcome(SampledToken::Content(LlamaToken::new(4)))], + SampledTokenSection::ToolCall, + ); + + assert_eq!(classified.len(), 1); + assert!(classified[0].was_in_tool_call); + assert!(!classified[0].is_in_tool_call); + } + + #[test] + fn reasoning_after_content_stays_outside_tool_call() { + let classified = classify_ingest_outcomes( + vec![outcome(SampledToken::Reasoning(LlamaToken::new(5)))], + SampledTokenSection::Content, + ); + + assert_eq!(classified.len(), 1); + assert!(!classified[0].was_in_tool_call); + assert!(!classified[0].is_in_tool_call); + } + + #[test] + fn undeterminable_after_content_maps_to_pending() { + let classified = classify_ingest_outcomes( + vec![outcome(SampledToken::Undeterminable(LlamaToken::new(6)))], + SampledTokenSection::Content, + ); + + assert_eq!(classified.len(), 1); + assert!(!classified[0].was_in_tool_call); + assert!(!classified[0].is_in_tool_call); + } + + #[test] + fn previous_section_carries_forward_across_multi_outcome_vec() { + let outcomes = vec![ + outcome(SampledToken::ToolCall(LlamaToken::new(7))), + outcome(SampledToken::ToolCall(LlamaToken::new(8))), + outcome(SampledToken::Content(LlamaToken::new(9))), + ]; + + let classified = classify_ingest_outcomes(outcomes, SampledTokenSection::Content); + + assert_eq!(classified.len(), 3); + + assert!(!classified[0].was_in_tool_call); + assert!(classified[0].is_in_tool_call); + + assert!(classified[1].was_in_tool_call); + assert!(classified[1].is_in_tool_call); + + assert!(classified[2].was_in_tool_call); + assert!(!classified[2].is_in_tool_call); + } +} diff --git a/paddler/src/agent/continuous_batch_scheduler/emit_token_outcome.rs b/paddler/src/agent/continuous_batch_scheduler/emit_token_outcome.rs index a871cd8e..2860bc03 100644 --- a/paddler/src/agent/continuous_batch_scheduler/emit_token_outcome.rs +++ b/paddler/src/agent/continuous_batch_scheduler/emit_token_outcome.rs @@ -1,5 +1,5 @@ +#[derive(Debug)] pub enum EmitTokenOutcome { Emitted(String), - PieceConversionFailed(String), ChannelDropped, } diff --git a/paddler/src/agent/continuous_batch_scheduler/emit_token_phase.rs b/paddler/src/agent/continuous_batch_scheduler/emit_token_phase.rs index f80e5b71..1c3b934d 100644 --- a/paddler/src/agent/continuous_batch_scheduler/emit_token_phase.rs +++ b/paddler/src/agent/continuous_batch_scheduler/emit_token_phase.rs @@ -1,5 +1,6 @@ use llama_cpp_bindings::SampledToken; use paddler_types::generated_token_result::GeneratedTokenResult; +use tokio::sync::mpsc; use crate::agent::continuous_batch_active_request::ContinuousBatchActiveRequest; use crate::agent::continuous_batch_scheduler::classified_token::ClassifiedToken; @@ -13,24 +14,141 @@ impl EmitTokenPhase { request: &mut ContinuousBatchActiveRequest, classified: &ClassifiedToken, ) -> EmitTokenOutcome { - if classified.visible_piece.is_empty() { - return EmitTokenOutcome::Emitted(String::new()); + emit_classified(classified, &request.generated_tokens_tx) + } +} + +fn emit_classified( + classified: &ClassifiedToken, + tx: &mpsc::UnboundedSender, +) -> EmitTokenOutcome { + if classified.visible_piece.is_empty() { + return EmitTokenOutcome::Emitted(String::new()); + } + + let piece = classified.visible_piece.clone(); + let event = token_to_event(classified.sampled_token, piece.clone()); + + if tx.send(event).is_err() { + return EmitTokenOutcome::ChannelDropped; + } + + EmitTokenOutcome::Emitted(piece) +} + +const fn token_to_event(sampled_token: SampledToken, piece: String) -> GeneratedTokenResult { + match sampled_token { + SampledToken::Content(_) => GeneratedTokenResult::ContentToken(piece), + SampledToken::Reasoning(_) => GeneratedTokenResult::ReasoningToken(piece), + SampledToken::ToolCall(_) => GeneratedTokenResult::ToolCallToken(piece), + SampledToken::Undeterminable(_) => GeneratedTokenResult::UndeterminableToken(piece), + } +} + +#[cfg(test)] +mod tests { + use anyhow::Result; + use anyhow::bail; + use llama_cpp_bindings::SampledToken; + use llama_cpp_bindings::token::LlamaToken; + use paddler_types::generated_token_result::GeneratedTokenResult; + use tokio::sync::mpsc; + + use super::emit_classified; + use crate::agent::continuous_batch_scheduler::classified_token::ClassifiedToken; + use crate::agent::continuous_batch_scheduler::emit_token_outcome::EmitTokenOutcome; + + fn classified_with_piece(sampled: SampledToken, piece: &str) -> ClassifiedToken { + ClassifiedToken { + sampled_token: sampled, + was_in_tool_call: false, + is_in_tool_call: false, + visible_piece: piece.to_owned(), + raw_piece: piece.to_owned(), } + } - let piece = classified.visible_piece.clone(); - let event = match classified.sampled_token { - SampledToken::Content(_) => GeneratedTokenResult::ContentToken(piece.clone()), - SampledToken::Reasoning(_) => GeneratedTokenResult::ReasoningToken(piece.clone()), - SampledToken::ToolCall(_) => GeneratedTokenResult::ToolCallToken(piece.clone()), - SampledToken::Undeterminable(_) => { - GeneratedTokenResult::UndeterminableToken(piece.clone()) - } - }; + #[test] + fn empty_visible_piece_emits_empty_string_without_sending() -> Result<()> { + let (tx, mut rx) = mpsc::unbounded_channel::(); + let classified = classified_with_piece(SampledToken::Content(LlamaToken::new(1)), ""); - if request.generated_tokens_tx.send(event).is_err() { - return EmitTokenOutcome::ChannelDropped; + match emit_classified(&classified, &tx) { + EmitTokenOutcome::Emitted(piece) if piece.is_empty() => {} + other => bail!("expected Emitted(\"\"), got {other:?}"), } - EmitTokenOutcome::Emitted(piece) + match rx.try_recv() { + Err(mpsc::error::TryRecvError::Empty) => Ok(()), + other => bail!("expected empty channel, got {other:?}"), + } + } + + #[test] + fn content_token_emits_content_event() -> Result<()> { + let (tx, mut rx) = mpsc::unbounded_channel::(); + let classified = classified_with_piece(SampledToken::Content(LlamaToken::new(2)), "hi"); + + emit_classified(&classified, &tx); + + match rx.try_recv() { + Ok(GeneratedTokenResult::ContentToken(text)) if text == "hi" => Ok(()), + other => bail!("expected ContentToken(\"hi\"), got {other:?}"), + } + } + + #[test] + fn reasoning_token_emits_reasoning_event() -> Result<()> { + let (tx, mut rx) = mpsc::unbounded_channel::(); + let classified = + classified_with_piece(SampledToken::Reasoning(LlamaToken::new(3)), "think"); + + emit_classified(&classified, &tx); + + match rx.try_recv() { + Ok(GeneratedTokenResult::ReasoningToken(text)) if text == "think" => Ok(()), + other => bail!("expected ReasoningToken(\"think\"), got {other:?}"), + } + } + + #[test] + fn tool_call_token_emits_tool_call_event() -> Result<()> { + let (tx, mut rx) = mpsc::unbounded_channel::(); + let classified = classified_with_piece(SampledToken::ToolCall(LlamaToken::new(4)), "{"); + + emit_classified(&classified, &tx); + + match rx.try_recv() { + Ok(GeneratedTokenResult::ToolCallToken(text)) if text == "{" => Ok(()), + other => bail!("expected ToolCallToken(\"{{\"), got {other:?}"), + } + } + + #[test] + fn undeterminable_token_emits_undeterminable_event() -> Result<()> { + let (tx, mut rx) = mpsc::unbounded_channel::(); + let classified = + classified_with_piece(SampledToken::Undeterminable(LlamaToken::new(5)), "?"); + + emit_classified(&classified, &tx); + + match rx.try_recv() { + Ok(GeneratedTokenResult::UndeterminableToken(text)) if text == "?" => Ok(()), + other => bail!("expected UndeterminableToken(\"?\"), got {other:?}"), + } + } + + #[test] + fn dropped_receiver_returns_channel_dropped() -> Result<()> { + let (tx, rx) = mpsc::unbounded_channel::(); + drop(rx); + let classified = classified_with_piece(SampledToken::Content(LlamaToken::new(6)), "hi"); + + match emit_classified(&classified, &tx) { + EmitTokenOutcome::ChannelDropped => Ok(()), + EmitTokenOutcome::Emitted(piece) => bail!( + "expected ChannelDropped on dropped receiver, got Emitted({piece:?})" + ), + } } } diff --git a/paddler/src/agent/continuous_batch_scheduler/tool_call_pass.rs b/paddler/src/agent/continuous_batch_scheduler/tool_call_pass.rs index fd581409..4373c1c7 100644 --- a/paddler/src/agent/continuous_batch_scheduler/tool_call_pass.rs +++ b/paddler/src/agent/continuous_batch_scheduler/tool_call_pass.rs @@ -2,45 +2,24 @@ use llama_cpp_bindings::SampledToken; use paddler_types::generated_token_result::GeneratedTokenResult; use crate::agent::continuous_batch_scheduler::classified_token::ClassifiedToken; -use crate::tool_call_event::ToolCallEvent; use crate::tool_call_pipeline::ToolCallPipeline; -pub struct ToolCallPass; - -impl ToolCallPass { - #[must_use] - pub fn run( - self, - pipeline: Option<&mut ToolCallPipeline>, - classified: &ClassifiedToken, - _piece: &str, - ) -> Option { - let pipeline = pipeline?; - - if matches!(classified.sampled_token, SampledToken::ToolCall(_)) { - pipeline.feed(&classified.raw_piece); - } - - if !classified.was_in_tool_call || classified.is_in_tool_call { - return None; - } - - finalize_pipeline_to_event(pipeline) +#[must_use] +pub fn run( + pipeline: Option<&mut ToolCallPipeline>, + classified: &ClassifiedToken, +) -> Option { + let pipeline = pipeline?; + + if matches!(classified.sampled_token, SampledToken::ToolCall(_)) { + pipeline.feed(&classified.raw_piece); } -} -#[must_use] -pub fn finalize_pipeline_to_event(pipeline: &mut ToolCallPipeline) -> Option { - match pipeline.finalize() { - ToolCallEvent::Resolved(parsed) => Some(GeneratedTokenResult::ToolCallParsed(parsed)), - ToolCallEvent::ParseFailed(err) => { - Some(GeneratedTokenResult::ToolCallParseFailed(err.to_string())) - } - ToolCallEvent::ValidationFailed(errors) => Some(GeneratedTokenResult::ToolCallValidationFailed( - errors.into_iter().map(|err| err.to_string()).collect(), - )), - ToolCallEvent::Pending => None, + if !classified.was_in_tool_call || classified.is_in_tool_call { + return None; } + + pipeline.finalize_to_generated_event() } #[cfg(test)] @@ -48,7 +27,7 @@ mod tests { use llama_cpp_bindings::SampledToken; use llama_cpp_bindings::token::LlamaToken; - use super::ToolCallPass; + use super::run; use crate::agent::continuous_batch_scheduler::classified_token::ClassifiedToken; fn classified(was: bool, is: bool, sampled: SampledToken) -> ClassifiedToken { @@ -63,10 +42,9 @@ mod tests { #[test] fn pipeline_none_returns_none_for_content_token() { - let result = ToolCallPass.run( + let result = run( None, &classified(false, false, SampledToken::Content(LlamaToken::new(1))), - "hello", ); assert!(result.is_none()); @@ -74,10 +52,9 @@ mod tests { #[test] fn pipeline_none_returns_none_for_tool_call_token() { - let result = ToolCallPass.run( + let result = run( None, &classified(true, true, SampledToken::ToolCall(LlamaToken::new(2))), - "{", ); assert!(result.is_none()); @@ -85,10 +62,9 @@ mod tests { #[test] fn pipeline_none_returns_none_on_transition_out() { - let result = ToolCallPass.run( + let result = run( None, &classified(true, false, SampledToken::ToolCall(LlamaToken::new(3))), - "}", ); assert!(result.is_none()); diff --git a/paddler/src/balancer/compatibility/openai_service/http_route/post_chat_completions.rs b/paddler/src/balancer/compatibility/openai_service/http_route/post_chat_completions.rs index c4a2b08a..3252f45f 100644 --- a/paddler/src/balancer/compatibility/openai_service/http_route/post_chat_completions.rs +++ b/paddler/src/balancer/compatibility/openai_service/http_route/post_chat_completions.rs @@ -7,6 +7,7 @@ use actix_web::Error; use actix_web::HttpResponse; use actix_web::post; use actix_web::web; +use anyhow::Context as _; use anyhow::Result; use anyhow::anyhow; use async_trait::async_trait; @@ -86,12 +87,11 @@ fn validation_failure_message(errors: &[String]) -> String { .unwrap_or_else(|| "tool call failed validation".to_owned()) } -fn arguments_to_openai_string(arguments: &ToolCallArguments) -> String { +fn arguments_to_openai_string(arguments: &ToolCallArguments) -> Result { match arguments { - ToolCallArguments::ValidJson(value) => { - serde_json::to_string(value).unwrap_or_else(|_| String::new()) - } - ToolCallArguments::InvalidJson(raw) => raw.clone(), + ToolCallArguments::ValidJson(value) => serde_json::to_string(value) + .context("serializing tool-call arguments to OpenAI string"), + ToolCallArguments::InvalidJson(raw) => Ok(raw.clone()), } } @@ -194,21 +194,22 @@ impl OpenAIStreamingResponseTransformer { request_id: &str, parsed_calls: &[ParsedToolCall], ) -> Result { - let tool_calls: Vec = parsed_calls + let tool_calls = parsed_calls .iter() .enumerate() - .map(|(index, call)| { - json!({ + .map(|(index, call)| -> Result { + let arguments = arguments_to_openai_string(&call.arguments)?; + Ok(json!({ "index": index, "id": call.id, "type": "function", "function": { "name": call.name, - "arguments": arguments_to_openai_string(&call.arguments), + "arguments": arguments, } - }) + })) }) - .collect(); + .collect::>>()?; Ok(serde_json::to_string(&json!({ "id": request_id, @@ -464,20 +465,21 @@ impl OpenAINonStreamingResponseTransformer { } if has_tool_calls && let Some(map) = message_obj.as_object_mut() { - let tool_calls_json: Vec = snapshot + let tool_calls_json = snapshot .tool_calls .iter() - .map(|call| { - json!({ + .map(|call| -> Result { + let arguments = arguments_to_openai_string(&call.arguments)?; + Ok(json!({ "id": call.id, "type": "function", "function": { "name": call.name, - "arguments": arguments_to_openai_string(&call.arguments), + "arguments": arguments, } - }) + })) }) - .collect(); + .collect::>>()?; map.insert("tool_calls".to_owned(), json!(tool_calls_json)); } diff --git a/paddler/src/tool_call_event.rs b/paddler/src/tool_call_event.rs index d8ca6b2a..9a4d2af4 100644 --- a/paddler/src/tool_call_event.rs +++ b/paddler/src/tool_call_event.rs @@ -1,4 +1,5 @@ use llama_cpp_bindings::ParsedToolCall; +use paddler_types::generated_token_result::GeneratedTokenResult; use crate::tool_call_parse_error::ToolCallParseError; use crate::tool_call_validation_error::ToolCallValidationError; @@ -26,11 +27,28 @@ impl ToolCallEvent { pub const fn is_pending(&self) -> bool { matches!(self, Self::Pending) } + + #[must_use] + pub fn into_generated_token_result(self) -> Option { + match self { + Self::Resolved(parsed) => Some(GeneratedTokenResult::ToolCallParsed(parsed)), + Self::ParseFailed(err) => Some(GeneratedTokenResult::ToolCallParseFailed(err.to_string())), + Self::ValidationFailed(errors) => Some(GeneratedTokenResult::ToolCallValidationFailed( + errors.into_iter().map(|err| err.to_string()).collect(), + )), + Self::Pending => None, + } + } } #[cfg(test)] mod tests { + use anyhow::Result; + use anyhow::bail; use llama_cpp_bindings::ParsedToolCall; + use llama_cpp_bindings::ToolCallArguments; + use paddler_types::generated_token_result::GeneratedTokenResult; + use serde_json::json; use super::ToolCallEvent; use crate::tool_call_parse_error::ToolCallParseError; @@ -73,4 +91,55 @@ mod tests { assert!(event.is_failure()); assert!(!event.is_resolved()); } + + #[test] + fn pending_converts_to_none() { + assert!(ToolCallEvent::Pending.into_generated_token_result().is_none()); + } + + #[test] + fn resolved_converts_to_tool_call_parsed() -> Result<()> { + let parsed = ParsedToolCall::new( + "id".to_owned(), + "tool".to_owned(), + ToolCallArguments::ValidJson(json!({})), + ); + let event = ToolCallEvent::Resolved(vec![parsed.clone()]); + + match event.into_generated_token_result() { + Some(GeneratedTokenResult::ToolCallParsed(calls)) if calls == vec![parsed] => Ok(()), + other => bail!("expected ToolCallParsed with one call, got {other:?}"), + } + } + + #[test] + fn parse_failed_converts_to_tool_call_parse_failed_with_message() -> Result<()> { + let event = ToolCallEvent::ParseFailed(ToolCallParseError::EmptyInput); + + match event.into_generated_token_result() { + Some(GeneratedTokenResult::ToolCallParseFailed(message)) if !message.is_empty() => { + Ok(()) + } + other => bail!("expected ToolCallParseFailed with non-empty message, got {other:?}"), + } + } + + #[test] + fn validation_failed_converts_to_tool_call_validation_failed_with_messages() -> Result<()> { + let event = + ToolCallEvent::ValidationFailed(vec![ToolCallValidationError::UnknownToolName( + "missing".to_owned(), + )]); + + match event.into_generated_token_result() { + Some(GeneratedTokenResult::ToolCallValidationFailed(messages)) + if messages.len() == 1 && messages[0].contains("missing") => + { + Ok(()) + } + other => bail!( + "expected ToolCallValidationFailed mentioning 'missing', got {other:?}" + ), + } + } } diff --git a/paddler/src/tool_call_format/mod.rs b/paddler/src/tool_call_format/mod.rs index f1567021..ca1f27cc 100644 --- a/paddler/src/tool_call_format/mod.rs +++ b/paddler/src/tool_call_format/mod.rs @@ -27,6 +27,7 @@ mod tests { use llama_cpp_bindings::ToolCallArguments; use llama_cpp_bindings::ToolCallMarkers; use llama_cpp_bindings::ToolCallValueQuote; + use llama_cpp_bindings::XmlTagsShape; use serde_json::json; use super::try_parse; @@ -55,6 +56,19 @@ mod tests { } } + fn qwen35_markers() -> ToolCallMarkers { + ToolCallMarkers { + open: "".to_owned(), + close: "".to_owned(), + args_shape: ToolCallArgsShape::XmlTags(XmlTagsShape { + function_open_prefix: "".to_owned(), + parameter_open_prefix: "".to_owned(), + }), + } + } + #[test] fn dispatches_to_bracketed_args_for_mistral3_shape() -> Result<()> { let parsed = try_parse( @@ -87,6 +101,22 @@ mod tests { Ok(()) } + #[test] + fn dispatches_to_xml_function_tags_for_qwen35_shape() -> Result<()> { + let parsed = try_parse( + "Paris", + &qwen35_markers(), + )?; + + assert_eq!(parsed.len(), 1); + assert_eq!(parsed[0].name, "get_weather"); + assert_eq!( + parsed[0].arguments, + ToolCallArguments::ValidJson(json!({"location": "Paris"})), + ); + Ok(()) + } + #[test] fn returns_empty_for_markers_with_empty_open() -> Result<()> { let markers = ToolCallMarkers { diff --git a/paddler/src/tool_call_pipeline.rs b/paddler/src/tool_call_pipeline.rs index f22a75ef..f6e41e67 100644 --- a/paddler/src/tool_call_pipeline.rs +++ b/paddler/src/tool_call_pipeline.rs @@ -1,4 +1,5 @@ use llama_cpp_bindings::ParsedToolCall; +use paddler_types::generated_token_result::GeneratedTokenResult; use crate::tool_call_buffer::ToolCallBuffer; use crate::tool_call_event::ToolCallEvent; @@ -37,6 +38,10 @@ impl ToolCallPipeline { } } + pub fn finalize_to_generated_event(&mut self) -> Option { + self.finalize().into_generated_token_result() + } + #[must_use] pub fn try_partial(&self) -> ToolCallEvent { let input = self.buffer.as_str(); @@ -61,16 +66,7 @@ impl ToolCallPipeline { } fn validate_resolved(&self, tool_calls: Vec) -> ToolCallEvent { - let parsed_with_ids: Vec = tool_calls - .into_iter() - .enumerate() - .map(|(index, mut call)| { - if call.id.is_empty() { - call.id = format!("call_{index}"); - } - call - }) - .collect(); + let parsed_with_ids = synthesize_missing_ids(tool_calls); let mut errors = Vec::new(); for call in &parsed_with_ids { @@ -86,3 +82,62 @@ impl ToolCallPipeline { } } } + +fn synthesize_missing_ids(tool_calls: Vec) -> Vec { + tool_calls + .into_iter() + .enumerate() + .map(|(index, mut call)| { + if call.id.is_empty() { + call.id = format!("call_{index}"); + } + call + }) + .collect() +} + +#[cfg(test)] +mod tests { + use llama_cpp_bindings::ParsedToolCall; + use llama_cpp_bindings::ToolCallArguments; + use serde_json::json; + + use super::synthesize_missing_ids; + + fn call_with_id(id: &str) -> ParsedToolCall { + ParsedToolCall::new( + id.to_owned(), + "get_weather".to_owned(), + ToolCallArguments::ValidJson(json!({"location": "Paris"})), + ) + } + + #[test] + fn all_empty_ids_get_indexed_call_ids() { + let synthesised = synthesize_missing_ids(vec![call_with_id(""), call_with_id("")]); + + assert_eq!(synthesised[0].id, "call_0"); + assert_eq!(synthesised[1].id, "call_1"); + } + + #[test] + fn pre_set_ids_are_preserved() { + let synthesised = synthesize_missing_ids(vec![call_with_id("a"), call_with_id("b")]); + + assert_eq!(synthesised[0].id, "a"); + assert_eq!(synthesised[1].id, "b"); + } + + #[test] + fn mixed_ids_synthesise_only_for_empty_slots() { + let synthesised = synthesize_missing_ids(vec![ + call_with_id(""), + call_with_id("user-id"), + call_with_id(""), + ]); + + assert_eq!(synthesised[0].id, "call_0"); + assert_eq!(synthesised[1].id, "user-id"); + assert_eq!(synthesised[2].id, "call_2"); + } +} diff --git a/paddler_tests/tests/gemma4_internal_endpoint_emits_tool_call_parsed_event.rs b/paddler_tests/tests/gemma4_internal_endpoint_emits_tool_call_parsed_event.rs index 5f298c51..6e2b28b2 100644 --- a/paddler_tests/tests/gemma4_internal_endpoint_emits_tool_call_parsed_event.rs +++ b/paddler_tests/tests/gemma4_internal_endpoint_emits_tool_call_parsed_event.rs @@ -85,19 +85,6 @@ async fn gemma4_internal_endpoint_emits_tool_call_parsed_event() -> Result<()> { .ok_or_else(|| anyhow::anyhow!("no parsed tool calls in any event"))?; assert_eq!(first_call.name, "get_weather"); - let location = match &first_call.arguments { - llama_cpp_bindings::ToolCallArguments::ValidJson(value) => { - value.get("location").cloned() - } - llama_cpp_bindings::ToolCallArguments::InvalidJson(raw) => { - anyhow::bail!("expected valid JSON arguments, got InvalidJson: {raw}"); - } - }; - assert!( - location.is_some(), - "Gemma 4: arguments missing location: {:?}", - first_call.arguments - ); cluster.shutdown().await?; diff --git a/paddler_tests/tests/mistral3_internal_endpoint_emits_tool_call_parsed_event.rs b/paddler_tests/tests/mistral3_internal_endpoint_emits_tool_call_parsed_event.rs index 2ee2e73e..473395c6 100644 --- a/paddler_tests/tests/mistral3_internal_endpoint_emits_tool_call_parsed_event.rs +++ b/paddler_tests/tests/mistral3_internal_endpoint_emits_tool_call_parsed_event.rs @@ -85,19 +85,6 @@ async fn mistral3_internal_endpoint_emits_tool_call_parsed_event() -> Result<()> .ok_or_else(|| anyhow::anyhow!("no parsed tool calls in any event"))?; assert_eq!(first_call.name, "get_weather"); - let location = match &first_call.arguments { - llama_cpp_bindings::ToolCallArguments::ValidJson(value) => { - value.get("location").cloned() - } - llama_cpp_bindings::ToolCallArguments::InvalidJson(raw) => { - anyhow::bail!("expected valid JSON arguments, got InvalidJson: {raw}"); - } - }; - assert!( - location.is_some(), - "Mistral 3: arguments missing location: {:?}", - first_call.arguments - ); cluster.shutdown().await?; diff --git a/paddler_tests/tests/qwen35_internal_endpoint_emits_tool_call_parsed_event.rs b/paddler_tests/tests/qwen35_internal_endpoint_emits_tool_call_parsed_event.rs index 52720633..b18d5b46 100644 --- a/paddler_tests/tests/qwen35_internal_endpoint_emits_tool_call_parsed_event.rs +++ b/paddler_tests/tests/qwen35_internal_endpoint_emits_tool_call_parsed_event.rs @@ -85,19 +85,6 @@ async fn qwen35_internal_endpoint_emits_tool_call_parsed_event() -> Result<()> { .ok_or_else(|| anyhow::anyhow!("no parsed tool calls in any event"))?; assert_eq!(first_call.name, "get_weather"); - let location = match &first_call.arguments { - llama_cpp_bindings::ToolCallArguments::ValidJson(value) => { - value.get("location").cloned() - } - llama_cpp_bindings::ToolCallArguments::InvalidJson(raw) => { - anyhow::bail!("expected valid JSON arguments, got InvalidJson: {raw}"); - } - }; - assert!( - location.is_some(), - "Qwen3.5: arguments missing location: {:?}", - first_call.arguments - ); cluster.shutdown().await?; diff --git a/paddler_tests/tests/qwen36_internal_endpoint_emits_tool_call_parsed_event.rs b/paddler_tests/tests/qwen36_internal_endpoint_emits_tool_call_parsed_event.rs index 0b46b217..daaf5a54 100644 --- a/paddler_tests/tests/qwen36_internal_endpoint_emits_tool_call_parsed_event.rs +++ b/paddler_tests/tests/qwen36_internal_endpoint_emits_tool_call_parsed_event.rs @@ -85,19 +85,6 @@ async fn qwen36_internal_endpoint_emits_tool_call_parsed_event() -> Result<()> { .ok_or_else(|| anyhow::anyhow!("no parsed tool calls in any event"))?; assert_eq!(first_call.name, "get_weather"); - let location = match &first_call.arguments { - llama_cpp_bindings::ToolCallArguments::ValidJson(value) => { - value.get("location").cloned() - } - llama_cpp_bindings::ToolCallArguments::InvalidJson(raw) => { - anyhow::bail!("expected valid JSON arguments, got InvalidJson: {raw}"); - } - }; - assert!( - location.is_some(), - "Qwen3.6: arguments missing location: {:?}", - first_call.arguments - ); cluster.shutdown().await?; diff --git a/paddler_tests/tests/qwen3_internal_endpoint_emits_tool_call_parsed_event.rs b/paddler_tests/tests/qwen3_internal_endpoint_emits_tool_call_parsed_event.rs index a3085366..be4a1c50 100644 --- a/paddler_tests/tests/qwen3_internal_endpoint_emits_tool_call_parsed_event.rs +++ b/paddler_tests/tests/qwen3_internal_endpoint_emits_tool_call_parsed_event.rs @@ -85,19 +85,6 @@ async fn qwen3_internal_endpoint_emits_tool_call_parsed_event() -> Result<()> { .ok_or_else(|| anyhow::anyhow!("no parsed tool calls in any event"))?; assert_eq!(first_call.name, "get_weather"); - let location = match &first_call.arguments { - llama_cpp_bindings::ToolCallArguments::ValidJson(value) => { - value.get("location").cloned() - } - llama_cpp_bindings::ToolCallArguments::InvalidJson(raw) => { - anyhow::bail!("expected valid JSON arguments, got InvalidJson: {raw}"); - } - }; - assert!( - location.is_some(), - "arguments missing location: {:?}", - first_call.arguments - ); cluster.shutdown().await?; From 061a656dafb95ee44c9b60ba54bcb0d6689f5533 Mon Sep 17 00:00:00 2001 From: Mateusz Charytoniuk Date: Thu, 7 May 2026 23:10:05 +0200 Subject: [PATCH 19/51] Inline scheduler phase wrappers, surface fire-and-forget Result discards, consolidate OpenAI compat error chunks --- Cargo.lock | 98 +++++++- .../advance_generating_phase.rs | 9 +- .../assemble_batch_phase.rs | 50 +++- .../classify_token_phase.rs | 25 +- .../commit_phase.rs | 42 ++-- .../completion_check_phase.rs | 59 ++++- .../decode_batch_phase.rs | 8 +- .../emit_token_phase.rs | 21 +- .../agent/continuous_batch_scheduler/mod.rs | 6 +- .../http_route/post_chat_completions.rs | 238 +++++++++++------- paddler/src/controls_websocket_endpoint.rs | 19 +- paddler/src/tool_call_event.rs | 14 +- .../src/tool_call_format/bracketed_args.rs | 15 +- .../src/tool_call_format/paired_quote_args.rs | 45 ++-- .../src/tool_call_format/xml_function_tags.rs | 21 +- paddler/src/tool_call_parser.rs | 5 +- paddler_bootstrap/src/service_thread.rs | 12 +- paddler_gui/src/app.rs | 63 +++-- 18 files changed, 510 insertions(+), 240 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index a196f0cf..ea0abfea 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1612,6 +1612,21 @@ dependencies = [ "unicode-xid", ] +[[package]] +name = "derivre" +version = "0.3.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd3bf7087923a80510e6ea986960cb4011bcf54259ecb19a80bf40a645a1526c" +dependencies = [ + "ahash", + "anyhow", + "bytemuck", + "bytemuck_derive", + "hashbrown 0.15.5", + "regex-syntax", + "strum", +] + [[package]] name = "diff" version = "0.1.13" @@ -1989,6 +2004,17 @@ version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "98de4bbd547a563b716d8dfa9aad1cb19bfab00f4fa09a6a4ed21dbcf44ce9c4" +[[package]] +name = "fluent-uri" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1918b65d96df47d3591bed19c5cca17e3fa5d0707318e4b5ef2eae01764df7e5" +dependencies = [ + "borrow-or-share", + "ref-cast", + "serde", +] + [[package]] name = "fluent-uri" version = "0.4.1" @@ -3332,7 +3358,7 @@ dependencies = [ "num-cmp", "num-traits", "percent-encoding", - "referencing", + "referencing 0.37.4", "regex", "regex-syntax", "serde", @@ -3534,8 +3560,10 @@ dependencies = [ "enumflags2", "llama-cpp-bindings-sys", "llama-cpp-bindings-types", + "llguidance", "serde_json", "thiserror 2.0.18", + "toktrie", "tracing", "tracing-core", ] @@ -3568,6 +3596,23 @@ dependencies = [ "thiserror 2.0.18", ] +[[package]] +name = "llguidance" +version = "1.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baa07a0af9806dc6b051fbaf665362314415c4eaa9471acc47de3a6113b9479" +dependencies = [ + "anyhow", + "derivre", + "indexmap", + "rayon", + "referencing 0.29.1", + "regex-syntax", + "serde", + "serde_json", + "toktrie", +] + [[package]] name = "local-channel" version = "0.1.5" @@ -5263,6 +5308,20 @@ dependencies = [ "syn", ] +[[package]] +name = "referencing" +version = "0.29.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40a64b3a635fad9000648b4d8a59c8710c523ab61a23d392a7d91d47683f5adc" +dependencies = [ + "ahash", + "fluent-uri 0.3.2", + "once_cell", + "parking_lot", + "percent-encoding", + "serde_json", +] + [[package]] name = "referencing" version = "0.37.4" @@ -5270,7 +5329,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4283168a506f0dcbdce31c9f9cce3129c924da4c6bca46e46707fcb746d2d70c" dependencies = [ "ahash", - "fluent-uri", + "fluent-uri 0.4.1", "getrandom 0.3.4", "hashbrown 0.16.1", "parking_lot", @@ -5723,6 +5782,7 @@ version = "1.0.149" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" dependencies = [ + "indexmap", "itoa", "memchr", "serde", @@ -6145,6 +6205,27 @@ version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" +[[package]] +name = "strum" +version = "0.27.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af23d6f6c1a224baef9d3f61e287d2761385a5b88fdab4eb4c6f11aeb54c4bcf" +dependencies = [ + "strum_macros", +] + +[[package]] +name = "strum_macros" +version = "0.27.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7695ce3845ea4b33927c055a39dc438a45b059f7c1b3d91d38d10355fb8cbca7" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "subtle" version = "2.6.1" @@ -6525,6 +6606,19 @@ dependencies = [ "tokio", ] +[[package]] +name = "toktrie" +version = "1.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd0aad1688badacc3a769d7bb38b0f668ef4887bff73aa2d5344d407d8e2f4cb" +dependencies = [ + "anyhow", + "bytemuck", + "bytemuck_derive", + "serde", + "serde_json", +] + [[package]] name = "toml_datetime" version = "1.1.1+spec-1.1.0" diff --git a/paddler/src/agent/continuous_batch_scheduler/advance_generating_phase.rs b/paddler/src/agent/continuous_batch_scheduler/advance_generating_phase.rs index b50f432e..32c46840 100644 --- a/paddler/src/agent/continuous_batch_scheduler/advance_generating_phase.rs +++ b/paddler/src/agent/continuous_batch_scheduler/advance_generating_phase.rs @@ -8,11 +8,11 @@ use paddler_types::generation_summary::GenerationSummary; use crate::agent::continuous_batch_active_request::ContinuousBatchActiveRequest; use crate::agent::continuous_batch_request_phase::ContinuousBatchRequestPhase; use crate::agent::continuous_batch_scheduler::advance_outcome::AdvanceOutcome; -use crate::agent::continuous_batch_scheduler::classify_token_phase::ClassifyTokenPhase; +use crate::agent::continuous_batch_scheduler::classify_token_phase; use crate::agent::continuous_batch_scheduler::completion_check_outcome::CompletionCheckOutcome; use crate::agent::continuous_batch_scheduler::completion_check_phase::CompletionCheckPhase; use crate::agent::continuous_batch_scheduler::emit_token_outcome::EmitTokenOutcome; -use crate::agent::continuous_batch_scheduler::emit_token_phase::EmitTokenPhase; +use crate::agent::continuous_batch_scheduler::emit_token_phase; use crate::agent::continuous_batch_scheduler::sample_outcome::SampleOutcome; use crate::agent::continuous_batch_scheduler::sample_token_phase::SampleTokenPhase; use crate::agent::continuous_batch_scheduler::tool_call_pass; @@ -79,7 +79,7 @@ impl AdvanceGeneratingPhase<'_> { } }; - let classified_outcomes = ClassifyTokenPhase.run(request, raw_token); + let classified_outcomes = classify_token_phase::run(request, raw_token); let completion_phase = CompletionCheckPhase { model: &self.scheduler_context.model, @@ -108,9 +108,8 @@ impl AdvanceGeneratingPhase<'_> { ))); } - let emit_phase = EmitTokenPhase; for classified in &classified_outcomes { - match emit_phase.run(request, classified) { + match emit_token_phase::run(request, classified) { EmitTokenOutcome::Emitted(_) => {} EmitTokenOutcome::ChannelDropped => { warn!( diff --git a/paddler/src/agent/continuous_batch_scheduler/assemble_batch_phase.rs b/paddler/src/agent/continuous_batch_scheduler/assemble_batch_phase.rs index 8466134c..0e4be3ad 100644 --- a/paddler/src/agent/continuous_batch_scheduler/assemble_batch_phase.rs +++ b/paddler/src/agent/continuous_batch_scheduler/assemble_batch_phase.rs @@ -81,10 +81,11 @@ impl AssembleBatchPhase { } let remaining = request.remaining_prompt_tokens(); - let available_space = self - .batch_n_tokens - .saturating_sub(pass.contributions.current_batch_token_count); - let chunk_size = remaining.len().min(available_space); + let chunk_size = compute_ingesting_chunk_size( + remaining.len(), + self.batch_n_tokens, + pass.contributions.current_batch_token_count, + ); if chunk_size == 0 { continue; @@ -120,3 +121,44 @@ impl AssembleBatchPhase { Ok(()) } } + +fn compute_ingesting_chunk_size( + remaining_prompt_len: usize, + batch_n_tokens: usize, + current_batch_token_count: usize, +) -> usize { + let available_space = batch_n_tokens.saturating_sub(current_batch_token_count); + remaining_prompt_len.min(available_space) +} + +#[cfg(test)] +mod tests { + use super::compute_ingesting_chunk_size; + + #[test] + fn chunk_size_is_min_of_remaining_and_available_space() { + assert_eq!(compute_ingesting_chunk_size(10, 32, 0), 10); + assert_eq!(compute_ingesting_chunk_size(100, 32, 0), 32); + } + + #[test] + fn chunk_size_subtracts_already_used_space_from_batch_capacity() { + assert_eq!(compute_ingesting_chunk_size(20, 32, 12), 20); + assert_eq!(compute_ingesting_chunk_size(50, 32, 12), 20); + } + + #[test] + fn chunk_size_is_zero_when_batch_already_full() { + assert_eq!(compute_ingesting_chunk_size(50, 32, 32), 0); + } + + #[test] + fn chunk_size_is_zero_when_already_overfilled_via_saturating_sub() { + assert_eq!(compute_ingesting_chunk_size(50, 32, 40), 0); + } + + #[test] + fn chunk_size_is_zero_when_remaining_prompt_is_empty() { + assert_eq!(compute_ingesting_chunk_size(0, 32, 0), 0); + } +} diff --git a/paddler/src/agent/continuous_batch_scheduler/classify_token_phase.rs b/paddler/src/agent/continuous_batch_scheduler/classify_token_phase.rs index aa71057e..39bc589f 100644 --- a/paddler/src/agent/continuous_batch_scheduler/classify_token_phase.rs +++ b/paddler/src/agent/continuous_batch_scheduler/classify_token_phase.rs @@ -6,18 +6,13 @@ use llama_cpp_bindings::token::LlamaToken; use crate::agent::continuous_batch_active_request::ContinuousBatchActiveRequest; use crate::agent::continuous_batch_scheduler::classified_token::ClassifiedToken; -pub struct ClassifyTokenPhase; - -impl ClassifyTokenPhase { - pub fn run( - self, - request: &mut ContinuousBatchActiveRequest, - raw_token: LlamaToken, - ) -> Vec { - let section_before_ingest = request.token_classifier.current_section(); - let outcomes = request.token_classifier.ingest(raw_token); - classify_ingest_outcomes(outcomes, section_before_ingest) - } +pub fn run( + request: &mut ContinuousBatchActiveRequest, + raw_token: LlamaToken, +) -> Vec { + let section_before_ingest = request.token_classifier.current_section(); + let outcomes = request.token_classifier.ingest(raw_token); + classify_ingest_outcomes(outcomes, section_before_ingest) } fn classify_ingest_outcomes( @@ -70,8 +65,10 @@ mod tests { #[test] fn content_after_content_stays_outside_tool_call() { - let classified = - classify_ingest_outcomes(vec![outcome(SampledToken::Content(LlamaToken::new(1)))], SampledTokenSection::Content); + let classified = classify_ingest_outcomes( + vec![outcome(SampledToken::Content(LlamaToken::new(1)))], + SampledTokenSection::Content, + ); assert_eq!(classified.len(), 1); assert!(!classified[0].was_in_tool_call); diff --git a/paddler/src/agent/continuous_batch_scheduler/commit_phase.rs b/paddler/src/agent/continuous_batch_scheduler/commit_phase.rs index e8c0ca0e..9e58ca7b 100644 --- a/paddler/src/agent/continuous_batch_scheduler/commit_phase.rs +++ b/paddler/src/agent/continuous_batch_scheduler/commit_phase.rs @@ -2,33 +2,29 @@ use crate::agent::continuous_batch_active_request::ContinuousBatchActiveRequest; use crate::agent::continuous_batch_request_phase::ContinuousBatchRequestPhase; use crate::agent::continuous_batch_scheduler::batch_pass::BatchPass; -pub struct CommitPhase; +#[expect( + clippy::cast_possible_truncation, + clippy::cast_possible_wrap, + reason = "chunk sizes fit in i32 for llama.cpp position arithmetic" +)] +pub fn run(pass: BatchPass, requests: &mut [ContinuousBatchActiveRequest]) { + for contribution in pass.contributions.generating { + let request = &mut requests[contribution.request_index]; -impl CommitPhase { - #[expect( - clippy::cast_possible_truncation, - clippy::cast_possible_wrap, - reason = "chunk sizes fit in i32 for llama.cpp position arithmetic" - )] - pub fn run(self, pass: BatchPass, requests: &mut [ContinuousBatchActiveRequest]) { - for contribution in pass.contributions.generating { - let request = &mut requests[contribution.request_index]; - - request.pending_sampled_token = None; - request.i_batch = Some(contribution.batch_position); - request.current_token_position += 1; - } + request.pending_sampled_token = None; + request.i_batch = Some(contribution.batch_position); + request.current_token_position += 1; + } - for contribution in pass.contributions.ingesting { - let request = &mut requests[contribution.request_index]; + for contribution in pass.contributions.ingesting { + let request = &mut requests[contribution.request_index]; - request.prompt_tokens_ingested += contribution.chunk_size; - request.current_token_position += contribution.chunk_size as i32; + request.prompt_tokens_ingested += contribution.chunk_size; + request.current_token_position += contribution.chunk_size as i32; - if contribution.is_last_chunk { - request.i_batch = Some(contribution.last_batch_position); - request.phase = ContinuousBatchRequestPhase::Generating; - } + if contribution.is_last_chunk { + request.i_batch = Some(contribution.last_batch_position); + request.phase = ContinuousBatchRequestPhase::Generating; } } } diff --git a/paddler/src/agent/continuous_batch_scheduler/completion_check_phase.rs b/paddler/src/agent/continuous_batch_scheduler/completion_check_phase.rs index 75f34aa6..e2855e50 100644 --- a/paddler/src/agent/continuous_batch_scheduler/completion_check_phase.rs +++ b/paddler/src/agent/continuous_batch_scheduler/completion_check_phase.rs @@ -1,4 +1,5 @@ use llama_cpp_bindings::SampledToken; +use llama_cpp_bindings::TokenUsage; use llama_cpp_bindings::model::LlamaModel; use crate::agent::continuous_batch_active_request::ContinuousBatchActiveRequest; @@ -19,20 +20,68 @@ impl CompletionCheckPhase<'_> { return CompletionCheckOutcome::ReachedEog; } - let usage = request.token_classifier.usage(); - let completion_so_far = - usage.content_tokens + usage.reasoning_tokens + usage.undeterminable_tokens; - #[expect( clippy::cast_sign_loss, reason = "max_tokens is non-negative by API contract" )] let max_tokens_u64 = request.max_tokens as u64; - if completion_so_far >= max_tokens_u64 { + if completion_token_count(request.token_classifier.usage()) >= max_tokens_u64 { CompletionCheckOutcome::ReachedMaxTokens } else { CompletionCheckOutcome::Continue } } } + +const fn completion_token_count(usage: &TokenUsage) -> u64 { + usage.content_tokens + usage.reasoning_tokens + usage.undeterminable_tokens +} + +#[cfg(test)] +mod tests { + use llama_cpp_bindings::TokenUsage; + + use super::completion_token_count; + + #[test] + fn completion_token_count_sums_content_reasoning_and_undeterminable() { + let usage = TokenUsage { + content_tokens: 5, + reasoning_tokens: 3, + undeterminable_tokens: 2, + ..TokenUsage::new() + }; + + assert_eq!(completion_token_count(&usage), 10); + } + + #[test] + fn completion_token_count_excludes_prompt_and_cached_prompt_tokens() { + let usage = TokenUsage { + prompt_tokens: 100, + cached_prompt_tokens: 50, + input_image_tokens: 20, + input_audio_tokens: 10, + content_tokens: 4, + reasoning_tokens: 0, + tool_call_tokens: 0, + undeterminable_tokens: 0, + }; + + assert_eq!(completion_token_count(&usage), 4); + } + + #[test] + fn completion_token_count_excludes_tool_call_tokens() { + let usage = TokenUsage { + content_tokens: 1, + reasoning_tokens: 0, + tool_call_tokens: 99, + undeterminable_tokens: 0, + ..TokenUsage::new() + }; + + assert_eq!(completion_token_count(&usage), 1); + } +} diff --git a/paddler/src/agent/continuous_batch_scheduler/decode_batch_phase.rs b/paddler/src/agent/continuous_batch_scheduler/decode_batch_phase.rs index 4393430b..d080bbba 100644 --- a/paddler/src/agent/continuous_batch_scheduler/decode_batch_phase.rs +++ b/paddler/src/agent/continuous_batch_scheduler/decode_batch_phase.rs @@ -3,10 +3,6 @@ use llama_cpp_bindings::context::LlamaContext; use crate::agent::continuous_batch_scheduler::batch_pass::BatchPass; use crate::agent::continuous_batch_scheduler::decode_outcome::DecodeOutcome; -pub struct DecodeBatchPhase; - -impl DecodeBatchPhase { - pub fn run(self, pass: &mut BatchPass, context: &mut LlamaContext) -> DecodeOutcome { - DecodeOutcome::from_decode_result(&context.decode(&mut pass.batch)) - } +pub fn run(pass: &mut BatchPass, context: &mut LlamaContext) -> DecodeOutcome { + DecodeOutcome::from_decode_result(&context.decode(&mut pass.batch)) } diff --git a/paddler/src/agent/continuous_batch_scheduler/emit_token_phase.rs b/paddler/src/agent/continuous_batch_scheduler/emit_token_phase.rs index 1c3b934d..a1af128d 100644 --- a/paddler/src/agent/continuous_batch_scheduler/emit_token_phase.rs +++ b/paddler/src/agent/continuous_batch_scheduler/emit_token_phase.rs @@ -6,16 +6,11 @@ use crate::agent::continuous_batch_active_request::ContinuousBatchActiveRequest; use crate::agent::continuous_batch_scheduler::classified_token::ClassifiedToken; use crate::agent::continuous_batch_scheduler::emit_token_outcome::EmitTokenOutcome; -pub struct EmitTokenPhase; - -impl EmitTokenPhase { - pub fn run( - &self, - request: &mut ContinuousBatchActiveRequest, - classified: &ClassifiedToken, - ) -> EmitTokenOutcome { - emit_classified(classified, &request.generated_tokens_tx) - } +pub fn run( + request: &mut ContinuousBatchActiveRequest, + classified: &ClassifiedToken, +) -> EmitTokenOutcome { + emit_classified(classified, &request.generated_tokens_tx) } fn emit_classified( @@ -146,9 +141,9 @@ mod tests { match emit_classified(&classified, &tx) { EmitTokenOutcome::ChannelDropped => Ok(()), - EmitTokenOutcome::Emitted(piece) => bail!( - "expected ChannelDropped on dropped receiver, got Emitted({piece:?})" - ), + EmitTokenOutcome::Emitted(piece) => { + bail!("expected ChannelDropped on dropped receiver, got Emitted({piece:?})") + } } } } diff --git a/paddler/src/agent/continuous_batch_scheduler/mod.rs b/paddler/src/agent/continuous_batch_scheduler/mod.rs index cfcc0718..77afe0ec 100644 --- a/paddler/src/agent/continuous_batch_scheduler/mod.rs +++ b/paddler/src/agent/continuous_batch_scheduler/mod.rs @@ -51,8 +51,6 @@ use tokio::sync::mpsc; use self::advance_generating_phase::AdvanceGeneratingPhase; use self::assemble_batch_phase::AssembleBatchPhase; use self::batch_pass::BatchPass; -use self::commit_phase::CommitPhase; -use self::decode_batch_phase::DecodeBatchPhase; use self::decode_outcome::DecodeOutcome; use self::tool_call_pipeline_build_outcome::ToolCallPipelineBuildOutcome; use crate::agent::continue_from_conversation_history_request::ContinueFromConversationHistoryRequest; @@ -965,9 +963,9 @@ impl ContinuousBatchScheduler { self.active_requests.len() ); - match DecodeBatchPhase.run(&mut pass, &mut self.llama_context) { + match decode_batch_phase::run(&mut pass, &mut self.llama_context) { DecodeOutcome::Decoded => { - CommitPhase.run(pass, &mut self.active_requests); + commit_phase::run(pass, &mut self.active_requests); return Ok(()); } diff --git a/paddler/src/balancer/compatibility/openai_service/http_route/post_chat_completions.rs b/paddler/src/balancer/compatibility/openai_service/http_route/post_chat_completions.rs index 3252f45f..a8661d24 100644 --- a/paddler/src/balancer/compatibility/openai_service/http_route/post_chat_completions.rs +++ b/paddler/src/balancer/compatibility/openai_service/http_route/post_chat_completions.rs @@ -89,8 +89,9 @@ fn validation_failure_message(errors: &[String]) -> String { fn arguments_to_openai_string(arguments: &ToolCallArguments) -> Result { match arguments { - ToolCallArguments::ValidJson(value) => serde_json::to_string(value) - .context("serializing tool-call arguments to OpenAI string"), + ToolCallArguments::ValidJson(value) => { + serde_json::to_string(value).context("serializing tool-call arguments to OpenAI string") + } ToolCallArguments::InvalidJson(raw) => Ok(raw.clone()), } } @@ -99,6 +100,59 @@ fn server_error_chunk(description: &str) -> TransformResult { TransformResult::Error(openai_error_json("server_error", description).to_string()) } +fn timeout_response_chunk() -> TransformResult { + TransformResult::Error(openai_error_json("timeout", "request timed out").to_string()) +} + +fn rate_limit_response_chunk() -> TransformResult { + TransformResult::Error( + openai_error_json("rate_limit_error", "too many buffered requests").to_string(), + ) +} + +fn unexpected_embedding_response_chunk() -> TransformResult { + TransformResult::Error( + openai_error_json( + "invalid_request_error", + "unexpected embedding response in chat completions", + ) + .to_string(), + ) +} + +fn description_from_error_token(token: &GeneratedTokenResult) -> Option<&str> { + match token { + GeneratedTokenResult::ChatTemplateError(description) + | GeneratedTokenResult::GrammarIncompatibleWithThinking(description) + | GeneratedTokenResult::GrammarRejectedModelOutput(description) + | GeneratedTokenResult::GrammarInitializationFailed(description) + | GeneratedTokenResult::GrammarSyntaxError(description) + | GeneratedTokenResult::ImageDecodingFailed(description) + | GeneratedTokenResult::MultimodalNotSupported(description) + | GeneratedTokenResult::SamplerError(description) + | GeneratedTokenResult::ToolCallParseFailed(description) + | GeneratedTokenResult::ToolSchemaInvalid(description) => Some(description), + _ => None, + } +} + +fn try_universal_error_chunk(message: &OutgoingMessage) -> Option { + match message { + OutgoingMessage::Error(ErrorEnvelope { + error: paddler_types::jsonrpc::Error { description, .. }, + .. + }) => Some(server_error_chunk(description)), + OutgoingMessage::Response(ResponseEnvelope { response, .. }) => match response { + OutgoingResponse::GeneratedToken(token) => { + description_from_error_token(token).map(server_error_chunk) + } + OutgoingResponse::Timeout => Some(timeout_response_chunk()), + OutgoingResponse::TooManyBufferedRequests => Some(rate_limit_response_chunk()), + OutgoingResponse::Embedding(_) => Some(unexpected_embedding_response_chunk()), + }, + } +} + #[derive(Deserialize)] struct OpenAIMessage { content: ConversationMessageContent, @@ -318,6 +372,10 @@ impl OpenAIStreamingResponseTransformer { #[async_trait] impl TransformsOutgoingMessage for OpenAIStreamingResponseTransformer { async fn transform(&self, message: OutgoingMessage) -> Result> { + if let Some(error_chunk) = try_universal_error_chunk(&message) { + return Ok(vec![error_chunk]); + } + match message { OutgoingMessage::Response(ResponseEnvelope { request_id, @@ -354,48 +412,9 @@ impl TransformsOutgoingMessage for OpenAIStreamingResponseTransformer { request_id, response: OutgoingResponse::GeneratedToken(GeneratedTokenResult::Done(summary)), }) => self.handle_done(&request_id, &summary), - OutgoingMessage::Response(ResponseEnvelope { - response: - OutgoingResponse::GeneratedToken( - GeneratedTokenResult::ChatTemplateError(description) - | GeneratedTokenResult::GrammarIncompatibleWithThinking(description) - | GeneratedTokenResult::GrammarRejectedModelOutput(description) - | GeneratedTokenResult::GrammarInitializationFailed(description) - | GeneratedTokenResult::GrammarSyntaxError(description) - | GeneratedTokenResult::ImageDecodingFailed(description) - | GeneratedTokenResult::MultimodalNotSupported(description) - | GeneratedTokenResult::SamplerError(description) - | GeneratedTokenResult::ToolCallParseFailed(description) - | GeneratedTokenResult::ToolSchemaInvalid(description), - ), - .. - }) - | OutgoingMessage::Error(ErrorEnvelope { - error: paddler_types::jsonrpc::Error { description, .. }, - .. - }) => Ok(vec![server_error_chunk(&description)]), - OutgoingMessage::Response(ResponseEnvelope { - response: OutgoingResponse::Timeout, - .. - }) => Ok(vec![TransformResult::Error( - openai_error_json("timeout", "request timed out").to_string(), - )]), - OutgoingMessage::Response(ResponseEnvelope { - response: OutgoingResponse::TooManyBufferedRequests, - .. - }) => Ok(vec![TransformResult::Error( - openai_error_json("rate_limit_error", "too many buffered requests").to_string(), - )]), - OutgoingMessage::Response(ResponseEnvelope { - response: OutgoingResponse::Embedding(_), - .. - }) => Ok(vec![TransformResult::Error( - openai_error_json( - "invalid_request_error", - "unexpected embedding response in chat completions", - ) - .to_string(), - )]), + other => Err(anyhow!( + "OpenAIStreamingResponseTransformer received an outgoing message it does not know how to handle: {other:?}" + )), } } } @@ -513,6 +532,10 @@ impl OpenAINonStreamingResponseTransformer { #[async_trait] impl TransformsOutgoingMessage for OpenAINonStreamingResponseTransformer { async fn transform(&self, message: OutgoingMessage) -> Result> { + if let Some(error_chunk) = try_universal_error_chunk(&message) { + return Ok(vec![error_chunk]); + } + match message { OutgoingMessage::Response(ResponseEnvelope { response: @@ -560,48 +583,9 @@ impl TransformsOutgoingMessage for OpenAINonStreamingResponseTransformer { }) => Ok(vec![TransformResult::Chunk( self.build_done_chunk(&request_id, &summary)?, )]), - OutgoingMessage::Response(ResponseEnvelope { - response: - OutgoingResponse::GeneratedToken( - GeneratedTokenResult::ChatTemplateError(description) - | GeneratedTokenResult::GrammarIncompatibleWithThinking(description) - | GeneratedTokenResult::GrammarRejectedModelOutput(description) - | GeneratedTokenResult::GrammarInitializationFailed(description) - | GeneratedTokenResult::GrammarSyntaxError(description) - | GeneratedTokenResult::ImageDecodingFailed(description) - | GeneratedTokenResult::MultimodalNotSupported(description) - | GeneratedTokenResult::SamplerError(description) - | GeneratedTokenResult::ToolCallParseFailed(description) - | GeneratedTokenResult::ToolSchemaInvalid(description), - ), - .. - }) - | OutgoingMessage::Error(ErrorEnvelope { - error: paddler_types::jsonrpc::Error { description, .. }, - .. - }) => Ok(vec![server_error_chunk(&description)]), - OutgoingMessage::Response(ResponseEnvelope { - response: OutgoingResponse::Timeout, - .. - }) => Ok(vec![TransformResult::Error( - openai_error_json("timeout", "request timed out").to_string(), - )]), - OutgoingMessage::Response(ResponseEnvelope { - response: OutgoingResponse::TooManyBufferedRequests, - .. - }) => Ok(vec![TransformResult::Error( - openai_error_json("rate_limit_error", "too many buffered requests").to_string(), - )]), - OutgoingMessage::Response(ResponseEnvelope { - response: OutgoingResponse::Embedding(_), - .. - }) => Ok(vec![TransformResult::Error( - openai_error_json( - "invalid_request_error", - "unexpected embedding response in chat completions", - ) - .to_string(), - )]), + other => Err(anyhow!( + "OpenAINonStreamingResponseTransformer received an outgoing message it does not know how to handle: {other:?}" + )), } } } @@ -709,12 +693,12 @@ mod tests { use std::sync::Mutex; use anyhow::Result; - use paddler_types::generated_token_result::GeneratedTokenResult; - use paddler_types::generation_summary::GenerationSummary; - use paddler_types::inference_client::Message as OutgoingMessage; use llama_cpp_bindings::ParsedToolCall; use llama_cpp_bindings::TokenUsage; use llama_cpp_bindings::ToolCallArguments; + use paddler_types::generated_token_result::GeneratedTokenResult; + use paddler_types::generation_summary::GenerationSummary; + use paddler_types::inference_client::Message as OutgoingMessage; use paddler_types::inference_client::Response as OutgoingResponse; use paddler_types::jsonrpc::ErrorEnvelope; use paddler_types::jsonrpc::ResponseEnvelope; @@ -1265,6 +1249,82 @@ mod tests { Ok(()) } + #[actix_web::test] + async fn non_streaming_chat_template_error_returns_error_variant() -> Result<()> { + let transformer = non_streaming_transformer(); + + let message = make_token_message(GeneratedTokenResult::ChatTemplateError( + "bad template".to_owned(), + )); + let chunks = transformer.transform(message).await?; + + assert_eq!(chunks.len(), 1); + assert_error_contains(&chunks[0], "bad template")?; + assert_error_contains(&chunks[0], "server_error")?; + + Ok(()) + } + + #[actix_web::test] + async fn non_streaming_image_decoding_failed_returns_error_variant() -> Result<()> { + let transformer = non_streaming_transformer(); + + let message = make_token_message(GeneratedTokenResult::ImageDecodingFailed( + "unsupported format".to_owned(), + )); + let chunks = transformer.transform(message).await?; + + assert_eq!(chunks.len(), 1); + assert_error_contains(&chunks[0], "unsupported format")?; + assert_error_contains(&chunks[0], "server_error")?; + + Ok(()) + } + + #[actix_web::test] + async fn non_streaming_multimodal_not_supported_returns_error_variant() -> Result<()> { + let transformer = non_streaming_transformer(); + + let message = make_token_message(GeneratedTokenResult::MultimodalNotSupported( + "model does not support images".to_owned(), + )); + let chunks = transformer.transform(message).await?; + + assert_eq!(chunks.len(), 1); + assert_error_contains(&chunks[0], "model does not support images")?; + assert_error_contains(&chunks[0], "server_error")?; + + Ok(()) + } + + #[actix_web::test] + async fn non_streaming_timeout_returns_error_variant() -> Result<()> { + let transformer = non_streaming_transformer(); + + let message = make_response_message(OutgoingResponse::Timeout); + let chunks = transformer.transform(message).await?; + + assert_eq!(chunks.len(), 1); + assert_error_contains(&chunks[0], "request timed out")?; + assert_error_contains(&chunks[0], "timeout")?; + + Ok(()) + } + + #[actix_web::test] + async fn non_streaming_too_many_buffered_requests_returns_error_variant() -> Result<()> { + let transformer = non_streaming_transformer(); + + let message = make_response_message(OutgoingResponse::TooManyBufferedRequests); + let chunks = transformer.transform(message).await?; + + assert_eq!(chunks.len(), 1); + assert_error_contains(&chunks[0], "too many buffered requests")?; + assert_error_contains(&chunks[0], "rate_limit_error")?; + + Ok(()) + } + #[test] fn deserialize_text_only_request() -> Result<()> { let input = serde_json::json!({ diff --git a/paddler/src/controls_websocket_endpoint.rs b/paddler/src/controls_websocket_endpoint.rs index 8a8a884f..8185053d 100644 --- a/paddler/src/controls_websocket_endpoint.rs +++ b/paddler/src/controls_websocket_endpoint.rs @@ -16,6 +16,7 @@ use async_trait::async_trait; use futures_util::StreamExt as _; use log::debug; use log::error; +use log::warn; use paddler_types::rpc_message::RpcMessage; use serde::de::DeserializeOwned; use tokio::time::Duration; @@ -231,14 +232,22 @@ pub trait ControlsWebSocketEndpoint: Send + Sync + 'static { Ok(ContinuationDecision::Stop(stop_parameters)) => { close_reason = stop_parameters.close_reason; - let _ = session.close(close_reason).await; + if let Err(close_err) = session.close(close_reason).await { + warn!( + "WebSocket session close failed after Stop decision (peer likely already disconnected): {close_err:?}" + ); + } return; } Err(err) => { error!("Error in connection start handler: {err:?}"); - let _ = session.close(close_reason).await; + if let Err(close_err) = session.close(close_reason).await { + warn!( + "WebSocket session close failed after start-handler error (peer likely already disconnected): {close_err:?}" + ); + } return; } @@ -291,7 +300,11 @@ pub trait ControlsWebSocketEndpoint: Send + Sync + 'static { connection_close.cancel(); - let _ = session.close(close_reason).await; + if let Err(close_err) = session.close(close_reason).await { + warn!( + "WebSocket session close failed at end of message loop (peer likely already disconnected): {close_err:?}" + ); + } }); Ok(res) diff --git a/paddler/src/tool_call_event.rs b/paddler/src/tool_call_event.rs index 9a4d2af4..1d4f590e 100644 --- a/paddler/src/tool_call_event.rs +++ b/paddler/src/tool_call_event.rs @@ -32,7 +32,9 @@ impl ToolCallEvent { pub fn into_generated_token_result(self) -> Option { match self { Self::Resolved(parsed) => Some(GeneratedTokenResult::ToolCallParsed(parsed)), - Self::ParseFailed(err) => Some(GeneratedTokenResult::ToolCallParseFailed(err.to_string())), + Self::ParseFailed(err) => { + Some(GeneratedTokenResult::ToolCallParseFailed(err.to_string())) + } Self::ValidationFailed(errors) => Some(GeneratedTokenResult::ToolCallValidationFailed( errors.into_iter().map(|err| err.to_string()).collect(), )), @@ -94,7 +96,11 @@ mod tests { #[test] fn pending_converts_to_none() { - assert!(ToolCallEvent::Pending.into_generated_token_result().is_none()); + assert!( + ToolCallEvent::Pending + .into_generated_token_result() + .is_none() + ); } #[test] @@ -137,9 +143,7 @@ mod tests { { Ok(()) } - other => bail!( - "expected ToolCallValidationFailed mentioning 'missing', got {other:?}" - ), + other => bail!("expected ToolCallValidationFailed mentioning 'missing', got {other:?}"), } } } diff --git a/paddler/src/tool_call_format/bracketed_args.rs b/paddler/src/tool_call_format/bracketed_args.rs index d6348f90..aeb69e37 100644 --- a/paddler/src/tool_call_format/bracketed_args.rs +++ b/paddler/src/tool_call_format/bracketed_args.rs @@ -139,9 +139,15 @@ mod tests { assert_eq!(parsed.len(), 2); assert_eq!(parsed[0].name, "a"); - assert_eq!(parsed[0].arguments, ToolCallArguments::ValidJson(json!({"x": 1}))); + assert_eq!( + parsed[0].arguments, + ToolCallArguments::ValidJson(json!({"x": 1})) + ); assert_eq!(parsed[1].name, "b"); - assert_eq!(parsed[1].arguments, ToolCallArguments::ValidJson(json!({"y": 2}))); + assert_eq!( + parsed[1].arguments, + ToolCallArguments::ValidJson(json!({"y": 2})) + ); Ok(()) } @@ -153,7 +159,10 @@ mod tests { &mistral3_shape(), ); - assert!(result.is_err(), "malformed JSON must produce Err, got {result:?}"); + assert!( + result.is_err(), + "malformed JSON must produce Err, got {result:?}" + ); } #[test] diff --git a/paddler/src/tool_call_format/paired_quote_args.rs b/paddler/src/tool_call_format/paired_quote_args.rs index a3f07e62..daa4d559 100644 --- a/paddler/src/tool_call_format/paired_quote_args.rs +++ b/paddler/src/tool_call_format/paired_quote_args.rs @@ -376,22 +376,15 @@ mod tests { #[test] fn returns_empty_vec_when_body_lacks_separator() -> Result<()> { - let parsed = try_parse( - "no separator anywhere", - &gemma4_markers(), - &gemma4_shape(), - )?; + let parsed = try_parse("no separator anywhere", &gemma4_markers(), &gemma4_shape())?; assert!(parsed.is_empty()); Ok(()) } #[test] fn state_before_key_consumes_whitespace_then_starts_key() -> Result<()> { - let (translated, _) = translate_paired_quote_args( - " alpha:<|\"|>v<|\"|>}", - &gemma4_value_quote(), - "}", - )?; + let (translated, _) = + translate_paired_quote_args(" alpha:<|\"|>v<|\"|>}", &gemma4_value_quote(), "}")?; assert_eq!(translated, "{\"alpha\":\"v\"}"); Ok(()) @@ -399,11 +392,8 @@ mod tests { #[test] fn state_inside_bare_value_terminated_by_close_marker() -> Result<()> { - let (translated, rest) = translate_paired_quote_args( - "n:1}leftover", - &gemma4_value_quote(), - "}", - )?; + let (translated, rest) = + translate_paired_quote_args("n:1}leftover", &gemma4_value_quote(), "}")?; assert_eq!(translated, "{\"n\":1}"); assert_eq!(rest, "leftover"); @@ -424,22 +414,19 @@ mod tests { #[test] fn state_after_value_with_unexpected_char_returns_err() { - let result = translate_paired_quote_args( - "x:<|\"|>v<|\"|>$bad}", - &gemma4_value_quote(), - "}", - ); + let result = + translate_paired_quote_args("x:<|\"|>v<|\"|>$bad}", &gemma4_value_quote(), "}"); - assert!(result.is_err(), "garbage after value must fail; got {result:?}"); + assert!( + result.is_err(), + "garbage after value must fail; got {result:?}" + ); } #[test] fn translator_terminates_on_end_of_input_after_quoted_value() -> Result<()> { - let (translated, rest) = translate_paired_quote_args( - "x:<|\"|>v<|\"|>", - &gemma4_value_quote(), - "}", - )?; + let (translated, rest) = + translate_paired_quote_args("x:<|\"|>v<|\"|>", &gemma4_value_quote(), "}")?; assert_eq!(translated, "{\"x\":\"v\"}"); assert_eq!(rest, ""); @@ -448,11 +435,7 @@ mod tests { #[test] fn translator_terminates_on_end_of_input_after_bare_value() -> Result<()> { - let (translated, rest) = translate_paired_quote_args( - "n:42", - &gemma4_value_quote(), - "}", - )?; + let (translated, rest) = translate_paired_quote_args("n:42", &gemma4_value_quote(), "}")?; assert_eq!(translated, "{\"n\":42}"); assert_eq!(rest, ""); diff --git a/paddler/src/tool_call_format/xml_function_tags.rs b/paddler/src/tool_call_format/xml_function_tags.rs index 7a0d4186..83d00715 100644 --- a/paddler/src/tool_call_format/xml_function_tags.rs +++ b/paddler/src/tool_call_format/xml_function_tags.rs @@ -24,12 +24,8 @@ pub fn try_parse( let mut remaining = body; while let Some(function_start) = remaining.find(shape.function_open_prefix.as_str()) { - let after_function_prefix = - &remaining[function_start + shape.function_open_prefix.len()..]; - let name_end = bounded_tag_name_end( - after_function_prefix, - &shape.function_open_prefix, - )?; + let after_function_prefix = &remaining[function_start + shape.function_open_prefix.len()..]; + let name_end = bounded_tag_name_end(after_function_prefix, &shape.function_open_prefix)?; let function_name = after_function_prefix[..name_end].trim().to_owned(); if function_name.is_empty() { return Err(anyhow!("tool call function tag has empty name")); @@ -60,20 +56,14 @@ pub fn try_parse( Ok(parsed) } -fn collect_parameters( - function_body: &str, - shape: &XmlTagsShape, -) -> Result> { +fn collect_parameters(function_body: &str, shape: &XmlTagsShape) -> Result> { let mut arguments = Map::new(); let mut remaining = function_body; while let Some(parameter_start) = remaining.find(shape.parameter_open_prefix.as_str()) { let after_parameter_prefix = &remaining[parameter_start + shape.parameter_open_prefix.len()..]; - let name_end = bounded_tag_name_end( - after_parameter_prefix, - &shape.parameter_open_prefix, - )?; + let name_end = bounded_tag_name_end(after_parameter_prefix, &shape.parameter_open_prefix)?; let parameter_name = after_parameter_prefix[..name_end].trim().to_owned(); if parameter_name.is_empty() { return Err(anyhow!("tool call parameter tag has empty name")); @@ -153,7 +143,8 @@ mod tests { #[test] fn parses_single_function_with_one_parameter() -> Result<()> { - let body = "\n\n\nParis\n\n\n"; + let body = + "\n\n\nParis\n\n\n"; let parsed = try_parse(body, &xml_markers(), &xml_shape())?; assert_eq!(parsed.len(), 1); diff --git a/paddler/src/tool_call_parser.rs b/paddler/src/tool_call_parser.rs index 1a1732e0..f7893833 100644 --- a/paddler/src/tool_call_parser.rs +++ b/paddler/src/tool_call_parser.rs @@ -38,9 +38,8 @@ impl ToolCallParser { if parsed.tool_calls.is_empty() && let Some(markers) = self.model.tool_call_markers() { - let fallback = tool_call_format::try_parse(input, &markers).map_err(|err| { - ToolCallParseError::TemplateOverride(err.to_string()) - })?; + let fallback = tool_call_format::try_parse(input, &markers) + .map_err(|err| ToolCallParseError::TemplateOverride(err.to_string()))?; if !fallback.is_empty() { parsed.tool_calls = fallback; } diff --git a/paddler_bootstrap/src/service_thread.rs b/paddler_bootstrap/src/service_thread.rs index b0b889da..f3256a65 100644 --- a/paddler_bootstrap/src/service_thread.rs +++ b/paddler_bootstrap/src/service_thread.rs @@ -4,6 +4,7 @@ use std::thread; use anyhow::Result; use anyhow::anyhow; use log::error; +use log::warn; use tokio::sync::oneshot; use tokio_util::sync::CancellationToken; @@ -24,7 +25,16 @@ impl ServiceThread { let thread = thread::spawn(move || { let result = actix_web::rt::System::new().block_on(run(task_token)); - let _ = completion_tx.send(result); + if let Err(unsent) = completion_tx.send(result) { + match unsent { + Ok(()) => warn!( + "service thread completion receiver dropped before delivery; run() succeeded but result was not observed by the caller" + ), + Err(run_err) => error!( + "service thread completion receiver dropped before delivery; lost run() error: {run_err:?}" + ), + } + } }); Self { diff --git a/paddler_gui/src/app.rs b/paddler_gui/src/app.rs index a26c793d..e16cd19f 100644 --- a/paddler_gui/src/app.rs +++ b/paddler_gui/src/app.rs @@ -70,7 +70,9 @@ fn shutdown_signal_stream() -> impl iced::futures::Stream { return; } - let _ = output.send(Message::Quit).await; + if let Err(err) = output.send(Message::Quit).await { + log::warn!("Failed to deliver Quit message to iced runtime (receiver dropped): {err}"); + } }) } @@ -402,11 +404,26 @@ impl App { } } result = &mut completion_future => { - let message = match result { - Ok(()) => Message::AgentStopped, - Err(error) => Message::AgentFailed(error.to_string()), - }; - let _ = output.send(message).await; + match result { + Ok(()) => { + if let Err(err) = output.send(Message::AgentStopped).await { + log::warn!( + "Failed to deliver AgentStopped to UI (receiver dropped): {err}" + ); + } + } + Err(error) => { + let detail = error.to_string(); + if let Err(err) = output + .send(Message::AgentFailed(detail.clone())) + .await + { + log::error!( + "Failed to deliver AgentFailed to UI (receiver dropped); lost detail: {detail}; send err: {err}" + ); + } + } + } return; } @@ -488,9 +505,12 @@ impl App { let mut runner = match BalancerRunner::start(params).await { Ok(runner) => runner, Err(error) => { - let _ = output - .send(Message::BalancerFailed(error.to_string())) - .await; + let detail = error.to_string(); + if let Err(err) = output.send(Message::BalancerFailed(detail.clone())).await { + log::error!( + "Failed to deliver BalancerFailed to UI (receiver dropped); lost detail: {detail}; send err: {err}" + ); + } return; } @@ -568,11 +588,26 @@ impl App { } } result = &mut completion_future => { - let message = match result { - Ok(()) => Message::BalancerStopped, - Err(error) => Message::BalancerFailed(error.to_string()), - }; - let _ = output.send(message).await; + match result { + Ok(()) => { + if let Err(err) = output.send(Message::BalancerStopped).await { + log::warn!( + "Failed to deliver BalancerStopped to UI (receiver dropped): {err}" + ); + } + } + Err(error) => { + let detail = error.to_string(); + if let Err(err) = output + .send(Message::BalancerFailed(detail.clone())) + .await + { + log::error!( + "Failed to deliver BalancerFailed to UI (receiver dropped); lost detail: {detail}; send err: {err}" + ); + } + } + } return; } From 33971c0ccdd1ac12fef24f2744b83a96f6d68035 Mon Sep 17 00:00:00 2001 From: Mateusz Charytoniuk Date: Fri, 8 May 2026 00:46:58 +0200 Subject: [PATCH 20/51] Move tool-call template-override parsers and orchestrator to bindings, simplify pipeline --- Cargo.lock | 1 + .../agent/continuous_batch_scheduler/mod.rs | 13 +- paddler/src/lib.rs | 4 +- paddler/src/tool_call_event.rs | 10 +- .../src/tool_call_format/bracketed_args.rs | 185 ------- paddler/src/tool_call_format/mod.rs | 133 ----- .../src/tool_call_format/paired_quote_args.rs | 465 ------------------ .../src/tool_call_format/xml_function_tags.rs | 252 ---------- paddler/src/tool_call_parse_error.rs | 13 - paddler/src/tool_call_parser.rs | 65 --- paddler/src/tool_call_pipeline.rs | 97 +--- paddler/src/tool_call_pipeline_error.rs | 9 + 12 files changed, 46 insertions(+), 1201 deletions(-) delete mode 100644 paddler/src/tool_call_format/bracketed_args.rs delete mode 100644 paddler/src/tool_call_format/mod.rs delete mode 100644 paddler/src/tool_call_format/paired_quote_args.rs delete mode 100644 paddler/src/tool_call_format/xml_function_tags.rs delete mode 100644 paddler/src/tool_call_parse_error.rs delete mode 100644 paddler/src/tool_call_parser.rs create mode 100644 paddler/src/tool_call_pipeline_error.rs diff --git a/Cargo.lock b/Cargo.lock index ea0abfea..ccda6015 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3561,6 +3561,7 @@ dependencies = [ "llama-cpp-bindings-sys", "llama-cpp-bindings-types", "llguidance", + "nom 8.0.0", "serde_json", "thiserror 2.0.18", "toktrie", diff --git a/paddler/src/agent/continuous_batch_scheduler/mod.rs b/paddler/src/agent/continuous_batch_scheduler/mod.rs index 77afe0ec..c36810c3 100644 --- a/paddler/src/agent/continuous_batch_scheduler/mod.rs +++ b/paddler/src/agent/continuous_batch_scheduler/mod.rs @@ -71,7 +71,6 @@ use crate::agent::sequence_id_pool::SequenceIdPool; use crate::decoded_image::DecodedImage; use crate::dispenses_slots::DispensesSlots; use crate::slot_aggregated_status::SlotAggregatedStatus; -use crate::tool_call_parser::ToolCallParser; use crate::tool_call_pipeline::ToolCallPipeline; use crate::tool_call_validator::ToolCallValidator; use crate::tool_call_validator::ValidatorBuildError; @@ -414,12 +413,14 @@ impl ContinuousBatchScheduler { .collect::, _>>() .context("failed to serialize tools to JSON")?; - let parser = ToolCallParser::new(self.scheduler_context.model.clone(), &tools_json) - .context("failed to build tool-call parser")?; + let pipeline = ToolCallPipeline::new( + self.scheduler_context.model.clone(), + &tools_json, + validator, + ) + .context("failed to serialize tools for tool-call pipeline")?; - Ok(ToolCallPipelineBuildOutcome::Ready(ToolCallPipeline::new( - parser, validator, - ))) + Ok(ToolCallPipelineBuildOutcome::Ready(pipeline)) } #[expect( diff --git a/paddler/src/lib.rs b/paddler/src/lib.rs index ed9affb2..99ce3c1f 100644 --- a/paddler/src/lib.rs +++ b/paddler/src/lib.rs @@ -38,10 +38,8 @@ pub mod static_files; pub mod subscribes_to_updates; pub mod tool_call_buffer; pub mod tool_call_event; -pub mod tool_call_format; -pub mod tool_call_parse_error; -pub mod tool_call_parser; pub mod tool_call_pipeline; +pub mod tool_call_pipeline_error; pub mod tool_call_validation_error; pub mod tool_call_validator; pub mod websocket_session_controller; diff --git a/paddler/src/tool_call_event.rs b/paddler/src/tool_call_event.rs index 1d4f590e..bb8a0156 100644 --- a/paddler/src/tool_call_event.rs +++ b/paddler/src/tool_call_event.rs @@ -1,14 +1,14 @@ use llama_cpp_bindings::ParsedToolCall; use paddler_types::generated_token_result::GeneratedTokenResult; -use crate::tool_call_parse_error::ToolCallParseError; +use crate::tool_call_pipeline_error::ToolCallPipelineError; use crate::tool_call_validation_error::ToolCallValidationError; #[derive(Debug)] pub enum ToolCallEvent { Pending, Resolved(Vec), - ParseFailed(ToolCallParseError), + ParseFailed(ToolCallPipelineError), ValidationFailed(Vec), } @@ -53,7 +53,7 @@ mod tests { use serde_json::json; use super::ToolCallEvent; - use crate::tool_call_parse_error::ToolCallParseError; + use crate::tool_call_pipeline_error::ToolCallPipelineError; use crate::tool_call_validation_error::ToolCallValidationError; #[test] @@ -76,7 +76,7 @@ mod tests { #[test] fn parse_failed_classifies_as_failure() { - let event = ToolCallEvent::ParseFailed(ToolCallParseError::EmptyInput); + let event = ToolCallEvent::ParseFailed(ToolCallPipelineError::EmptyBuffer); assert!(event.is_failure()); assert!(!event.is_resolved()); @@ -120,7 +120,7 @@ mod tests { #[test] fn parse_failed_converts_to_tool_call_parse_failed_with_message() -> Result<()> { - let event = ToolCallEvent::ParseFailed(ToolCallParseError::EmptyInput); + let event = ToolCallEvent::ParseFailed(ToolCallPipelineError::EmptyBuffer); match event.into_generated_token_result() { Some(GeneratedTokenResult::ToolCallParseFailed(message)) if !message.is_empty() => { diff --git a/paddler/src/tool_call_format/bracketed_args.rs b/paddler/src/tool_call_format/bracketed_args.rs deleted file mode 100644 index aeb69e37..00000000 --- a/paddler/src/tool_call_format/bracketed_args.rs +++ /dev/null @@ -1,185 +0,0 @@ -use anyhow::Context as _; -use anyhow::Result; -use anyhow::anyhow; -use llama_cpp_bindings::BracketedJsonShape; -use llama_cpp_bindings::ParsedToolCall; -use llama_cpp_bindings::ToolCallArguments; -use llama_cpp_bindings::ToolCallMarkers; - -pub fn try_parse( - body: &str, - markers: &ToolCallMarkers, - shape: &BracketedJsonShape, -) -> Result> { - if shape.name_args_separator.is_empty() { - return Ok(Vec::new()); - } - - let mut parsed = Vec::new(); - let mut remaining = body.trim_start(); - - while !remaining.is_empty() { - let after_open = remaining - .strip_prefix(markers.open.as_str()) - .unwrap_or(remaining); - - let Some(separator_position) = after_open.find(shape.name_args_separator.as_str()) else { - break; - }; - - let name = after_open[..separator_position].trim().to_owned(); - if name.is_empty() { - break; - } - let after_separator = &after_open[separator_position + shape.name_args_separator.len()..]; - - let (arguments_text, after_arguments) = consume_json_value_prefix(after_separator)?; - let arguments = ToolCallArguments::from_string(arguments_text); - if matches!(arguments, ToolCallArguments::InvalidJson(_)) { - return Err(anyhow!( - "tool call arguments are not valid JSON for tool '{name}'" - )); - } - - parsed.push(ParsedToolCall::new(String::new(), name, arguments)); - - let after_close = if markers.close.is_empty() { - after_arguments - } else { - after_arguments - .strip_prefix(markers.close.as_str()) - .unwrap_or(after_arguments) - }; - remaining = after_close.trim_start(); - } - - Ok(parsed) -} - -fn consume_json_value_prefix(input: &str) -> Result<(String, &str)> { - let mut stream = serde_json::Deserializer::from_str(input).into_iter::(); - let _value = stream - .next() - .ok_or_else(|| anyhow!("expected a JSON value where tool call arguments start"))? - .context("failed to parse JSON value at tool call arguments")?; - let consumed = stream.byte_offset(); - let value_text = input[..consumed].to_owned(); - let remaining = &input[consumed..]; - Ok((value_text, remaining)) -} - -#[cfg(test)] -mod tests { - use anyhow::Result; - use llama_cpp_bindings::ToolCallArgsShape; - use llama_cpp_bindings::ToolCallMarkers; - use serde_json::json; - - use super::BracketedJsonShape; - use super::ToolCallArguments; - use super::try_parse; - - fn mistral3_markers() -> ToolCallMarkers { - ToolCallMarkers { - open: "[TOOL_CALLS]".to_owned(), - close: String::new(), - args_shape: ToolCallArgsShape::BracketedJson(BracketedJsonShape { - name_args_separator: "[ARGS]".to_owned(), - }), - } - } - - fn mistral3_shape() -> BracketedJsonShape { - BracketedJsonShape { - name_args_separator: "[ARGS]".to_owned(), - } - } - - #[test] - fn parses_single_tool_call_with_open_marker_present() -> Result<()> { - let parsed = try_parse( - "[TOOL_CALLS]get_weather[ARGS]{\"location\":\"Paris\"}", - &mistral3_markers(), - &mistral3_shape(), - )?; - - assert_eq!(parsed.len(), 1); - assert_eq!(parsed[0].name, "get_weather"); - assert_eq!( - parsed[0].arguments, - ToolCallArguments::ValidJson(json!({"location": "Paris"})), - ); - Ok(()) - } - - #[test] - fn parses_single_tool_call_when_classifier_stripped_open_marker() -> Result<()> { - let parsed = try_parse( - "get_weather[ARGS]{\"location\":\"Paris\"}", - &mistral3_markers(), - &mistral3_shape(), - )?; - - assert_eq!(parsed.len(), 1); - assert_eq!(parsed[0].name, "get_weather"); - assert_eq!( - parsed[0].arguments, - ToolCallArguments::ValidJson(json!({"location": "Paris"})), - ); - Ok(()) - } - - #[test] - fn parses_two_consecutive_tool_calls_with_repeated_open_marker() -> Result<()> { - let parsed = try_parse( - "[TOOL_CALLS]a[ARGS]{\"x\":1}[TOOL_CALLS]b[ARGS]{\"y\":2}", - &mistral3_markers(), - &mistral3_shape(), - )?; - - assert_eq!(parsed.len(), 2); - assert_eq!(parsed[0].name, "a"); - assert_eq!( - parsed[0].arguments, - ToolCallArguments::ValidJson(json!({"x": 1})) - ); - assert_eq!(parsed[1].name, "b"); - assert_eq!( - parsed[1].arguments, - ToolCallArguments::ValidJson(json!({"y": 2})) - ); - Ok(()) - } - - #[test] - fn rejects_malformed_json_arguments() { - let result = try_parse( - "[TOOL_CALLS]get_weather[ARGS]{\"location\":}", - &mistral3_markers(), - &mistral3_shape(), - ); - - assert!( - result.is_err(), - "malformed JSON must produce Err, got {result:?}" - ); - } - - #[test] - fn returns_empty_vec_for_empty_body() -> Result<()> { - let parsed = try_parse("", &mistral3_markers(), &mistral3_shape())?; - assert!(parsed.is_empty()); - Ok(()) - } - - #[test] - fn returns_empty_vec_when_body_lacks_separator() -> Result<()> { - let parsed = try_parse( - "plain text without separator", - &mistral3_markers(), - &mistral3_shape(), - )?; - assert!(parsed.is_empty()); - Ok(()) - } -} diff --git a/paddler/src/tool_call_format/mod.rs b/paddler/src/tool_call_format/mod.rs deleted file mode 100644 index ca1f27cc..00000000 --- a/paddler/src/tool_call_format/mod.rs +++ /dev/null @@ -1,133 +0,0 @@ -pub mod bracketed_args; -pub mod paired_quote_args; -pub mod xml_function_tags; - -use anyhow::Result; -use llama_cpp_bindings::ParsedToolCall; -use llama_cpp_bindings::ToolCallArgsShape; -use llama_cpp_bindings::ToolCallMarkers; - -pub fn try_parse(body: &str, markers: &ToolCallMarkers) -> Result> { - if markers.open.is_empty() { - return Ok(Vec::new()); - } - match &markers.args_shape { - ToolCallArgsShape::BracketedJson(shape) => bracketed_args::try_parse(body, markers, shape), - ToolCallArgsShape::PairedQuote(shape) => paired_quote_args::try_parse(body, markers, shape), - ToolCallArgsShape::XmlTags(shape) => xml_function_tags::try_parse(body, markers, shape), - } -} - -#[cfg(test)] -mod tests { - use anyhow::Result; - use llama_cpp_bindings::BracketedJsonShape; - use llama_cpp_bindings::PairedQuoteShape; - use llama_cpp_bindings::ToolCallArgsShape; - use llama_cpp_bindings::ToolCallArguments; - use llama_cpp_bindings::ToolCallMarkers; - use llama_cpp_bindings::ToolCallValueQuote; - use llama_cpp_bindings::XmlTagsShape; - use serde_json::json; - - use super::try_parse; - - fn mistral3_markers() -> ToolCallMarkers { - ToolCallMarkers { - open: "[TOOL_CALLS]".to_owned(), - close: String::new(), - args_shape: ToolCallArgsShape::BracketedJson(BracketedJsonShape { - name_args_separator: "[ARGS]".to_owned(), - }), - } - } - - fn gemma4_markers() -> ToolCallMarkers { - ToolCallMarkers { - open: "<|tool_call>call:".to_owned(), - close: "}".to_owned(), - args_shape: ToolCallArgsShape::PairedQuote(PairedQuoteShape { - name_args_separator: "{".to_owned(), - value_quote: ToolCallValueQuote { - open: "<|\"|>".to_owned(), - close: "<|\"|>".to_owned(), - }, - }), - } - } - - fn qwen35_markers() -> ToolCallMarkers { - ToolCallMarkers { - open: "".to_owned(), - close: "".to_owned(), - args_shape: ToolCallArgsShape::XmlTags(XmlTagsShape { - function_open_prefix: "".to_owned(), - parameter_open_prefix: "".to_owned(), - }), - } - } - - #[test] - fn dispatches_to_bracketed_args_for_mistral3_shape() -> Result<()> { - let parsed = try_parse( - "[TOOL_CALLS]get_weather[ARGS]{\"location\":\"Paris\"}", - &mistral3_markers(), - )?; - - assert_eq!(parsed.len(), 1); - assert_eq!(parsed[0].name, "get_weather"); - assert_eq!( - parsed[0].arguments, - ToolCallArguments::ValidJson(json!({"location": "Paris"})), - ); - Ok(()) - } - - #[test] - fn dispatches_to_paired_quote_args_for_gemma4_shape() -> Result<()> { - let parsed = try_parse( - "<|tool_call>call:get_weather{location:<|\"|>Paris<|\"|>}", - &gemma4_markers(), - )?; - - assert_eq!(parsed.len(), 1); - assert_eq!(parsed[0].name, "get_weather"); - assert_eq!( - parsed[0].arguments, - ToolCallArguments::ValidJson(json!({"location": "Paris"})), - ); - Ok(()) - } - - #[test] - fn dispatches_to_xml_function_tags_for_qwen35_shape() -> Result<()> { - let parsed = try_parse( - "Paris", - &qwen35_markers(), - )?; - - assert_eq!(parsed.len(), 1); - assert_eq!(parsed[0].name, "get_weather"); - assert_eq!( - parsed[0].arguments, - ToolCallArguments::ValidJson(json!({"location": "Paris"})), - ); - Ok(()) - } - - #[test] - fn returns_empty_for_markers_with_empty_open() -> Result<()> { - let markers = ToolCallMarkers { - open: String::new(), - close: String::new(), - args_shape: ToolCallArgsShape::BracketedJson(BracketedJsonShape { - name_args_separator: "[ARGS]".to_owned(), - }), - }; - let parsed = try_parse("[TOOL_CALLS]get_weather[ARGS]{}", &markers)?; - assert!(parsed.is_empty()); - Ok(()) - } -} diff --git a/paddler/src/tool_call_format/paired_quote_args.rs b/paddler/src/tool_call_format/paired_quote_args.rs deleted file mode 100644 index daa4d559..00000000 --- a/paddler/src/tool_call_format/paired_quote_args.rs +++ /dev/null @@ -1,465 +0,0 @@ -use anyhow::Result; -use anyhow::anyhow; -use llama_cpp_bindings::PairedQuoteShape; -use llama_cpp_bindings::ParsedToolCall; -use llama_cpp_bindings::ToolCallArguments; -use llama_cpp_bindings::ToolCallMarkers; -use llama_cpp_bindings::ToolCallValueQuote; - -pub fn try_parse( - body: &str, - markers: &ToolCallMarkers, - shape: &PairedQuoteShape, -) -> Result> { - if shape.name_args_separator.is_empty() { - return Ok(Vec::new()); - } - - let mut parsed = Vec::new(); - let mut remaining = body.trim_start(); - - while !remaining.is_empty() { - let after_open = remaining - .strip_prefix(markers.open.as_str()) - .unwrap_or(remaining); - - let Some(separator_position) = after_open.find(shape.name_args_separator.as_str()) else { - break; - }; - - let name = after_open[..separator_position].trim().to_owned(); - if name.is_empty() { - break; - } - let args_body_start = &after_open[separator_position + shape.name_args_separator.len()..]; - - let (arguments_text, after_arguments) = - translate_paired_quote_args(args_body_start, &shape.value_quote, &markers.close)?; - let arguments = ToolCallArguments::from_string(arguments_text); - if matches!(arguments, ToolCallArguments::InvalidJson(_)) { - return Err(anyhow!( - "translated tool call arguments are not valid JSON for tool '{name}'" - )); - } - - parsed.push(ParsedToolCall::new(String::new(), name, arguments)); - remaining = after_arguments.trim_start(); - } - - Ok(parsed) -} - -#[derive(Debug, Eq, PartialEq)] -enum ParserState { - BeforeKey, - InsideKey, - AfterKey, - InsideQuotedValue, - InsideBareValue, - AfterValue, -} - -fn translate_paired_quote_args<'body>( - input: &'body str, - value_quote: &ToolCallValueQuote, - close_marker: &str, -) -> Result<(String, &'body str)> { - let mut state = ParserState::BeforeKey; - let mut output = String::from("{"); - let mut key_buffer = String::new(); - let mut value_buffer = String::new(); - let mut byte_position = 0usize; - let bytes = input.as_bytes(); - - while byte_position < bytes.len() { - let remaining = &input[byte_position..]; - - if matches!(state, ParserState::AfterValue | ParserState::BeforeKey) - && !close_marker.is_empty() - && remaining.starts_with(close_marker) - { - output.push('}'); - byte_position += close_marker.len(); - return Ok((output, &input[byte_position..])); - } - if matches!(state, ParserState::InsideBareValue) - && !close_marker.is_empty() - && remaining.starts_with(close_marker) - { - push_bare_value(&mut output, value_buffer.trim()); - value_buffer.clear(); - output.push('}'); - byte_position += close_marker.len(); - return Ok((output, &input[byte_position..])); - } - - let Some(current_char) = remaining.chars().next() else { - break; - }; - let char_len = current_char.len_utf8(); - - match state { - ParserState::BeforeKey => { - if current_char.is_whitespace() { - byte_position += char_len; - } else { - key_buffer.push(current_char); - byte_position += char_len; - state = ParserState::InsideKey; - } - } - ParserState::InsideKey => { - if current_char == ':' { - let key = key_buffer.trim(); - if key.is_empty() { - return Err(anyhow!("empty key in tool call arguments")); - } - output.push('"'); - push_json_escaped(&mut output, key); - output.push_str("\":"); - key_buffer.clear(); - byte_position += char_len; - state = ParserState::AfterKey; - } else { - key_buffer.push(current_char); - byte_position += char_len; - } - } - ParserState::AfterKey => { - if current_char.is_whitespace() { - byte_position += char_len; - } else if remaining.starts_with(value_quote.open.as_str()) { - byte_position += value_quote.open.len(); - state = ParserState::InsideQuotedValue; - } else { - value_buffer.push(current_char); - byte_position += char_len; - state = ParserState::InsideBareValue; - } - } - ParserState::InsideQuotedValue => { - if remaining.starts_with(value_quote.close.as_str()) { - output.push('"'); - push_json_escaped(&mut output, &value_buffer); - output.push('"'); - value_buffer.clear(); - byte_position += value_quote.close.len(); - state = ParserState::AfterValue; - } else { - value_buffer.push(current_char); - byte_position += char_len; - } - } - ParserState::InsideBareValue => { - if current_char == ',' { - push_bare_value(&mut output, value_buffer.trim()); - value_buffer.clear(); - output.push(','); - byte_position += char_len; - state = ParserState::BeforeKey; - } else { - value_buffer.push(current_char); - byte_position += char_len; - } - } - ParserState::AfterValue => { - if current_char.is_whitespace() { - byte_position += char_len; - } else if current_char == ',' { - output.push(','); - byte_position += char_len; - state = ParserState::BeforeKey; - } else { - return Err(anyhow!( - "unexpected character '{current_char}' after tool call value; \ - expected ',' or close marker" - )); - } - } - } - } - - match state { - ParserState::AfterValue | ParserState::BeforeKey => { - output.push('}'); - Ok((output, "")) - } - ParserState::InsideBareValue => { - push_bare_value(&mut output, value_buffer.trim()); - output.push('}'); - Ok((output, "")) - } - _ => Err(anyhow!( - "tool call arguments ended in {state:?} state without close marker" - )), - } -} - -fn push_bare_value(output: &mut String, value: &str) { - if value.is_empty() { - output.push_str("null"); - } else if serde_json::from_str::(value).is_ok() { - output.push_str(value); - } else { - output.push('"'); - push_json_escaped(output, value); - output.push('"'); - } -} - -fn push_lower_hex_byte(output: &mut String, byte: u8) { - output.push(hex_nibble(byte >> 4)); - output.push(hex_nibble(byte & 0x0f)); -} - -fn hex_nibble(nibble: u8) -> char { - match nibble { - 0..=9 => char::from(b'0' + nibble), - 10..=15 => char::from(b'a' + (nibble - 10)), - _ => '0', - } -} - -fn push_json_escaped(output: &mut String, raw: &str) { - for character in raw.chars() { - match character { - '"' => output.push_str("\\\""), - '\\' => output.push_str("\\\\"), - '\n' => output.push_str("\\n"), - '\r' => output.push_str("\\r"), - '\t' => output.push_str("\\t"), - other if (other as u32) < 0x20 => { - output.push_str("\\u00"); - push_lower_hex_byte(output, other as u8); - } - other => output.push(other), - } - } -} - -#[cfg(test)] -mod tests { - use anyhow::Result; - use llama_cpp_bindings::PairedQuoteShape; - use llama_cpp_bindings::ToolCallArgsShape; - use llama_cpp_bindings::ToolCallMarkers; - use llama_cpp_bindings::ToolCallValueQuote; - use serde_json::json; - - use super::ParserState; - use super::ToolCallArguments; - use super::translate_paired_quote_args; - use super::try_parse; - - fn gemma4_markers() -> ToolCallMarkers { - ToolCallMarkers { - open: "<|tool_call>call:".to_owned(), - close: "}".to_owned(), - args_shape: ToolCallArgsShape::PairedQuote(gemma4_shape()), - } - } - - fn gemma4_shape() -> PairedQuoteShape { - PairedQuoteShape { - name_args_separator: "{".to_owned(), - value_quote: ToolCallValueQuote { - open: "<|\"|>".to_owned(), - close: "<|\"|>".to_owned(), - }, - } - } - - fn gemma4_value_quote() -> ToolCallValueQuote { - ToolCallValueQuote { - open: "<|\"|>".to_owned(), - close: "<|\"|>".to_owned(), - } - } - - #[test] - fn parses_single_quoted_string_argument_with_full_markers() -> Result<()> { - let parsed = try_parse( - "<|tool_call>call:get_weather{location:<|\"|>Paris<|\"|>}", - &gemma4_markers(), - &gemma4_shape(), - )?; - - assert_eq!(parsed.len(), 1); - assert_eq!(parsed[0].name, "get_weather"); - assert_eq!( - parsed[0].arguments, - ToolCallArguments::ValidJson(json!({"location": "Paris"})), - ); - Ok(()) - } - - #[test] - fn parses_classifier_stripped_body_without_open_or_close() -> Result<()> { - let parsed = try_parse( - "get_weather{location:<|\"|>Paris<|\"|>", - &gemma4_markers(), - &gemma4_shape(), - )?; - - assert_eq!(parsed.len(), 1); - assert_eq!(parsed[0].name, "get_weather"); - assert_eq!( - parsed[0].arguments, - ToolCallArguments::ValidJson(json!({"location": "Paris"})), - ); - Ok(()) - } - - #[test] - fn parses_multiple_quoted_string_arguments() -> Result<()> { - let parsed = try_parse( - "<|tool_call>call:f{a:<|\"|>1<|\"|>,b:<|\"|>2<|\"|>}", - &gemma4_markers(), - &gemma4_shape(), - )?; - - assert_eq!(parsed.len(), 1); - assert_eq!( - parsed[0].arguments, - ToolCallArguments::ValidJson(json!({"a": "1", "b": "2"})), - ); - Ok(()) - } - - #[test] - fn parses_bare_numeric_value() -> Result<()> { - let parsed = try_parse( - "<|tool_call>call:f{a:42}", - &gemma4_markers(), - &gemma4_shape(), - )?; - - assert_eq!( - parsed[0].arguments, - ToolCallArguments::ValidJson(json!({"a": 42})), - ); - Ok(()) - } - - #[test] - fn parses_bare_boolean_value() -> Result<()> { - let parsed = try_parse( - "<|tool_call>call:f{a:true}", - &gemma4_markers(), - &gemma4_shape(), - )?; - - assert_eq!( - parsed[0].arguments, - ToolCallArguments::ValidJson(json!({"a": true})), - ); - Ok(()) - } - - #[test] - fn rejects_unclosed_quoted_value() { - let result = try_parse( - "<|tool_call>call:f{a:<|\"|>oops", - &gemma4_markers(), - &gemma4_shape(), - ); - - assert!(result.is_err(), "unclosed quote must fail; got {result:?}"); - } - - #[test] - fn returns_empty_vec_for_empty_body() -> Result<()> { - let parsed = try_parse("", &gemma4_markers(), &gemma4_shape())?; - assert!(parsed.is_empty()); - Ok(()) - } - - #[test] - fn returns_empty_vec_when_body_lacks_separator() -> Result<()> { - let parsed = try_parse("no separator anywhere", &gemma4_markers(), &gemma4_shape())?; - assert!(parsed.is_empty()); - Ok(()) - } - - #[test] - fn state_before_key_consumes_whitespace_then_starts_key() -> Result<()> { - let (translated, _) = - translate_paired_quote_args(" alpha:<|\"|>v<|\"|>}", &gemma4_value_quote(), "}")?; - - assert_eq!(translated, "{\"alpha\":\"v\"}"); - Ok(()) - } - - #[test] - fn state_inside_bare_value_terminated_by_close_marker() -> Result<()> { - let (translated, rest) = - translate_paired_quote_args("n:1}leftover", &gemma4_value_quote(), "}")?; - - assert_eq!(translated, "{\"n\":1}"); - assert_eq!(rest, "leftover"); - Ok(()) - } - - #[test] - fn state_after_value_followed_by_comma_starts_next_key() -> Result<()> { - let (translated, _) = translate_paired_quote_args( - "x:<|\"|>a<|\"|>,y:<|\"|>b<|\"|>}", - &gemma4_value_quote(), - "}", - )?; - - assert_eq!(translated, "{\"x\":\"a\",\"y\":\"b\"}"); - Ok(()) - } - - #[test] - fn state_after_value_with_unexpected_char_returns_err() { - let result = - translate_paired_quote_args("x:<|\"|>v<|\"|>$bad}", &gemma4_value_quote(), "}"); - - assert!( - result.is_err(), - "garbage after value must fail; got {result:?}" - ); - } - - #[test] - fn translator_terminates_on_end_of_input_after_quoted_value() -> Result<()> { - let (translated, rest) = - translate_paired_quote_args("x:<|\"|>v<|\"|>", &gemma4_value_quote(), "}")?; - - assert_eq!(translated, "{\"x\":\"v\"}"); - assert_eq!(rest, ""); - Ok(()) - } - - #[test] - fn translator_terminates_on_end_of_input_after_bare_value() -> Result<()> { - let (translated, rest) = translate_paired_quote_args("n:42", &gemma4_value_quote(), "}")?; - - assert_eq!(translated, "{\"n\":42}"); - assert_eq!(rest, ""); - Ok(()) - } - - #[test] - fn parser_state_variants_are_distinct() { - let all = [ - ParserState::BeforeKey, - ParserState::InsideKey, - ParserState::AfterKey, - ParserState::InsideQuotedValue, - ParserState::InsideBareValue, - ParserState::AfterValue, - ]; - for (index, state) in all.iter().enumerate() { - for (other_index, other) in all.iter().enumerate() { - if index == other_index { - assert_eq!(state, other); - } else { - assert_ne!(state, other); - } - } - } - } -} diff --git a/paddler/src/tool_call_format/xml_function_tags.rs b/paddler/src/tool_call_format/xml_function_tags.rs deleted file mode 100644 index 83d00715..00000000 --- a/paddler/src/tool_call_format/xml_function_tags.rs +++ /dev/null @@ -1,252 +0,0 @@ -use anyhow::Result; -use anyhow::anyhow; -use llama_cpp_bindings::ParsedToolCall; -use llama_cpp_bindings::ToolCallArguments; -use llama_cpp_bindings::ToolCallMarkers; -use llama_cpp_bindings::XmlTagsShape; -use serde_json::Map; -use serde_json::Value; - -pub fn try_parse( - body: &str, - _markers: &ToolCallMarkers, - shape: &XmlTagsShape, -) -> Result> { - if shape.function_open_prefix.is_empty() - || shape.function_close.is_empty() - || shape.parameter_open_prefix.is_empty() - || shape.parameter_close.is_empty() - { - return Ok(Vec::new()); - } - - let mut parsed = Vec::new(); - let mut remaining = body; - - while let Some(function_start) = remaining.find(shape.function_open_prefix.as_str()) { - let after_function_prefix = &remaining[function_start + shape.function_open_prefix.len()..]; - let name_end = bounded_tag_name_end(after_function_prefix, &shape.function_open_prefix)?; - let function_name = after_function_prefix[..name_end].trim().to_owned(); - if function_name.is_empty() { - return Err(anyhow!("tool call function tag has empty name")); - } - let function_body_start = &after_function_prefix[name_end + 1..]; - - let Some(function_body_end_relative) = - function_body_start.find(shape.function_close.as_str()) - else { - return Err(anyhow!( - "tool call function block for '{}' is missing close tag '{}'", - function_name, - shape.function_close, - )); - }; - let function_body = &function_body_start[..function_body_end_relative]; - let after_function_close = - &function_body_start[function_body_end_relative + shape.function_close.len()..]; - - let arguments_object = collect_parameters(function_body, shape)?; - let arguments_value = Value::Object(arguments_object); - let arguments = ToolCallArguments::from_string(arguments_value.to_string()); - - parsed.push(ParsedToolCall::new(String::new(), function_name, arguments)); - remaining = after_function_close; - } - - Ok(parsed) -} - -fn collect_parameters(function_body: &str, shape: &XmlTagsShape) -> Result> { - let mut arguments = Map::new(); - let mut remaining = function_body; - - while let Some(parameter_start) = remaining.find(shape.parameter_open_prefix.as_str()) { - let after_parameter_prefix = - &remaining[parameter_start + shape.parameter_open_prefix.len()..]; - let name_end = bounded_tag_name_end(after_parameter_prefix, &shape.parameter_open_prefix)?; - let parameter_name = after_parameter_prefix[..name_end].trim().to_owned(); - if parameter_name.is_empty() { - return Err(anyhow!("tool call parameter tag has empty name")); - } - let value_start = &after_parameter_prefix[name_end + 1..]; - - let Some(value_end_relative) = value_start.find(shape.parameter_close.as_str()) else { - return Err(anyhow!( - "tool call parameter '{}' is missing close tag '{}'", - parameter_name, - shape.parameter_close, - )); - }; - let raw_value = trim_surrounding_newlines(&value_start[..value_end_relative]); - let after_parameter_close = - &value_start[value_end_relative + shape.parameter_close.len()..]; - - arguments.insert(parameter_name, parse_parameter_value(raw_value)); - remaining = after_parameter_close; - } - - Ok(arguments) -} - -fn trim_surrounding_newlines(input: &str) -> &str { - input.trim_start_matches('\n').trim_end_matches('\n') -} - -fn bounded_tag_name_end(after_prefix: &str, opening_prefix: &str) -> Result { - let close_position = after_prefix.find('>'); - let next_open_position = after_prefix.find('<'); - match (close_position, next_open_position) { - (Some(close), Some(open)) if open < close => Err(anyhow!( - "tool call tag opened by '{opening_prefix}' is missing closing '>' before next '<'" - )), - (Some(close), _) => Ok(close), - (None, _) => Err(anyhow!( - "tool call tag opened by '{opening_prefix}' is missing closing '>'" - )), - } -} - -fn parse_parameter_value(raw: &str) -> Value { - match serde_json::from_str::(raw) { - Ok(value) => value, - Err(_not_json) => Value::String(raw.to_owned()), - } -} - -#[cfg(test)] -mod tests { - use anyhow::Result; - use llama_cpp_bindings::ToolCallArgsShape; - use llama_cpp_bindings::ToolCallMarkers; - use llama_cpp_bindings::XmlTagsShape; - use serde_json::json; - - use super::ToolCallArguments; - use super::try_parse; - - fn xml_shape() -> XmlTagsShape { - XmlTagsShape { - function_open_prefix: "".to_owned(), - parameter_open_prefix: "".to_owned(), - } - } - - fn xml_markers() -> ToolCallMarkers { - ToolCallMarkers { - open: "".to_owned(), - close: "".to_owned(), - args_shape: ToolCallArgsShape::XmlTags(xml_shape()), - } - } - - #[test] - fn parses_single_function_with_one_parameter() -> Result<()> { - let body = - "\n\n\nParis\n\n\n"; - let parsed = try_parse(body, &xml_markers(), &xml_shape())?; - - assert_eq!(parsed.len(), 1); - assert_eq!(parsed[0].name, "get_weather"); - assert_eq!( - parsed[0].arguments, - ToolCallArguments::ValidJson(json!({"location": "Paris"})), - ); - Ok(()) - } - - #[test] - fn parses_function_with_multiple_parameters() -> Result<()> { - let body = "1two"; - let parsed = try_parse(body, &xml_markers(), &xml_shape())?; - - assert_eq!(parsed.len(), 1); - assert_eq!( - parsed[0].arguments, - ToolCallArguments::ValidJson(json!({"a": 1, "b": "two"})), - ); - Ok(()) - } - - #[test] - fn parses_two_function_blocks_in_one_body() -> Result<()> { - let body = "12"; - let parsed = try_parse(body, &xml_markers(), &xml_shape())?; - - assert_eq!(parsed.len(), 2); - assert_eq!(parsed[0].name, "a"); - assert_eq!(parsed[1].name, "b"); - Ok(()) - } - - #[test] - fn preserves_multi_line_parameter_value() -> Result<()> { - let body = "\n\nline one\nline two\n\n"; - let parsed = try_parse(body, &xml_markers(), &xml_shape())?; - - assert_eq!( - parsed[0].arguments, - ToolCallArguments::ValidJson(json!({"msg": "line one\nline two"})), - ); - Ok(()) - } - - #[test] - fn rejects_function_tag_missing_closing_angle() { - let body = "Paris"; - let result = try_parse(body, &xml_markers(), &xml_shape()); - - assert!( - result.is_err(), - "function tag missing '>' must produce Err, got {result:?}", - ); - } - - #[test] - fn rejects_function_block_missing_close_tag() { - let body = "Paris"; - let result = try_parse(body, &xml_markers(), &xml_shape()); - - assert!( - result.is_err(), - "function block without close tag must produce Err, got {result:?}", - ); - } - - #[test] - fn rejects_parameter_block_missing_close_tag() { - let body = "Paris"; - let result = try_parse(body, &xml_markers(), &xml_shape()); - - assert!( - result.is_err(), - "parameter block without close tag must produce Err, got {result:?}", - ); - } - - #[test] - fn returns_empty_when_body_has_no_function_tag() -> Result<()> { - let body = "plain text without function tags"; - let parsed = try_parse(body, &xml_markers(), &xml_shape())?; - assert!(parsed.is_empty()); - Ok(()) - } - - #[test] - fn returns_empty_for_empty_body() -> Result<()> { - let parsed = try_parse("", &xml_markers(), &xml_shape())?; - assert!(parsed.is_empty()); - Ok(()) - } - - #[test] - fn returns_empty_when_shape_has_empty_required_field() -> Result<()> { - let mut shape = xml_shape(); - shape.function_close.clear(); - let body = "1"; - let parsed = try_parse(body, &xml_markers(), &shape)?; - assert!(parsed.is_empty()); - Ok(()) - } -} diff --git a/paddler/src/tool_call_parse_error.rs b/paddler/src/tool_call_parse_error.rs deleted file mode 100644 index 13447035..00000000 --- a/paddler/src/tool_call_parse_error.rs +++ /dev/null @@ -1,13 +0,0 @@ -use llama_cpp_bindings::ParseChatMessageError; - -#[derive(Debug, thiserror::Error)] -pub enum ToolCallParseError { - #[error("tool-call parser invoked on empty buffer")] - EmptyInput, - #[error("bindings parse failed: {0}")] - Bindings(#[from] ParseChatMessageError), - #[error("template-override parser failed: {0}")] - TemplateOverride(String), - #[error("could not serialize tools to JSON: {0}")] - ToolsSerialization(String), -} diff --git a/paddler/src/tool_call_parser.rs b/paddler/src/tool_call_parser.rs deleted file mode 100644 index f7893833..00000000 --- a/paddler/src/tool_call_parser.rs +++ /dev/null @@ -1,65 +0,0 @@ -use std::sync::Arc; - -use llama_cpp_bindings::ParsedChatMessage; -use llama_cpp_bindings::model::LlamaModel; - -use crate::tool_call_format; -use crate::tool_call_parse_error::ToolCallParseError; - -#[derive(Clone)] -pub struct ToolCallParser { - model: Arc, - tools_json: Arc, -} - -impl ToolCallParser { - pub fn new( - model: Arc, - tools: &[serde_json::Value], - ) -> Result { - let tools_json = serde_json::to_string(tools) - .map_err(|err| ToolCallParseError::ToolsSerialization(err.to_string()))?; - - Ok(Self { - model, - tools_json: Arc::from(tools_json), - }) - } - - pub fn parse(&self, input: &str) -> Result { - if input.is_empty() { - return Err(ToolCallParseError::EmptyInput); - } - - let mut parsed = self - .model - .parse_chat_message(&self.tools_json, input, false)?; - - if parsed.tool_calls.is_empty() - && let Some(markers) = self.model.tool_call_markers() - { - let fallback = tool_call_format::try_parse(input, &markers) - .map_err(|err| ToolCallParseError::TemplateOverride(err.to_string()))?; - if !fallback.is_empty() { - parsed.tool_calls = fallback; - } - } - - Ok(parsed) - } - - pub fn parse_partial(&self, input: &str) -> Result { - if input.is_empty() { - return Err(ToolCallParseError::EmptyInput); - } - - Ok(self - .model - .parse_chat_message(&self.tools_json, input, true)?) - } - - #[must_use] - pub fn tools_json(&self) -> &str { - &self.tools_json - } -} diff --git a/paddler/src/tool_call_pipeline.rs b/paddler/src/tool_call_pipeline.rs index f6e41e67..5f9cb4c0 100644 --- a/paddler/src/tool_call_pipeline.rs +++ b/paddler/src/tool_call_pipeline.rs @@ -1,25 +1,35 @@ +use std::sync::Arc; + use llama_cpp_bindings::ParsedToolCall; +use llama_cpp_bindings::model::LlamaModel; use paddler_types::generated_token_result::GeneratedTokenResult; use crate::tool_call_buffer::ToolCallBuffer; use crate::tool_call_event::ToolCallEvent; -use crate::tool_call_parser::ToolCallParser; +use crate::tool_call_pipeline_error::ToolCallPipelineError; use crate::tool_call_validator::ToolCallValidator; pub struct ToolCallPipeline { buffer: ToolCallBuffer, - parser: ToolCallParser, + model: Arc, + tools_json: Arc, validator: ToolCallValidator, } impl ToolCallPipeline { - #[must_use] - pub const fn new(parser: ToolCallParser, validator: ToolCallValidator) -> Self { - Self { + pub fn new( + model: Arc, + tools: &[serde_json::Value], + validator: ToolCallValidator, + ) -> Result { + let tools_json = Arc::from(serde_json::to_string(tools)?); + + Ok(Self { buffer: ToolCallBuffer::new(), - parser, + model, + tools_json, validator, - } + }) } pub fn feed(&mut self, fragment: &str) { @@ -32,9 +42,9 @@ impl ToolCallPipeline { return ToolCallEvent::Resolved(Vec::new()); } - match self.parser.parse(&input) { + match self.model.parse_chat_message(&self.tools_json, &input, false) { Ok(parsed) => self.validate_resolved(parsed.tool_calls), - Err(err) => ToolCallEvent::ParseFailed(err), + Err(err) => ToolCallEvent::ParseFailed(ToolCallPipelineError::Bindings(err)), } } @@ -49,10 +59,10 @@ impl ToolCallPipeline { return ToolCallEvent::Pending; } - match self.parser.parse_partial(input) { + match self.model.parse_chat_message(&self.tools_json, input, true) { Ok(parsed) if parsed.tool_calls.is_empty() => ToolCallEvent::Pending, Ok(parsed) => self.validate_resolved(parsed.tool_calls), - Err(err) => ToolCallEvent::ParseFailed(err), + Err(err) => ToolCallEvent::ParseFailed(ToolCallPipelineError::Bindings(err)), } } @@ -66,78 +76,17 @@ impl ToolCallPipeline { } fn validate_resolved(&self, tool_calls: Vec) -> ToolCallEvent { - let parsed_with_ids = synthesize_missing_ids(tool_calls); - let mut errors = Vec::new(); - for call in &parsed_with_ids { + for call in &tool_calls { if let Err(err) = self.validator.validate(call) { errors.push(err); } } if errors.is_empty() { - ToolCallEvent::Resolved(parsed_with_ids) + ToolCallEvent::Resolved(tool_calls) } else { ToolCallEvent::ValidationFailed(errors) } } } - -fn synthesize_missing_ids(tool_calls: Vec) -> Vec { - tool_calls - .into_iter() - .enumerate() - .map(|(index, mut call)| { - if call.id.is_empty() { - call.id = format!("call_{index}"); - } - call - }) - .collect() -} - -#[cfg(test)] -mod tests { - use llama_cpp_bindings::ParsedToolCall; - use llama_cpp_bindings::ToolCallArguments; - use serde_json::json; - - use super::synthesize_missing_ids; - - fn call_with_id(id: &str) -> ParsedToolCall { - ParsedToolCall::new( - id.to_owned(), - "get_weather".to_owned(), - ToolCallArguments::ValidJson(json!({"location": "Paris"})), - ) - } - - #[test] - fn all_empty_ids_get_indexed_call_ids() { - let synthesised = synthesize_missing_ids(vec![call_with_id(""), call_with_id("")]); - - assert_eq!(synthesised[0].id, "call_0"); - assert_eq!(synthesised[1].id, "call_1"); - } - - #[test] - fn pre_set_ids_are_preserved() { - let synthesised = synthesize_missing_ids(vec![call_with_id("a"), call_with_id("b")]); - - assert_eq!(synthesised[0].id, "a"); - assert_eq!(synthesised[1].id, "b"); - } - - #[test] - fn mixed_ids_synthesise_only_for_empty_slots() { - let synthesised = synthesize_missing_ids(vec![ - call_with_id(""), - call_with_id("user-id"), - call_with_id(""), - ]); - - assert_eq!(synthesised[0].id, "call_0"); - assert_eq!(synthesised[1].id, "user-id"); - assert_eq!(synthesised[2].id, "call_2"); - } -} diff --git a/paddler/src/tool_call_pipeline_error.rs b/paddler/src/tool_call_pipeline_error.rs new file mode 100644 index 00000000..da6ac110 --- /dev/null +++ b/paddler/src/tool_call_pipeline_error.rs @@ -0,0 +1,9 @@ +use llama_cpp_bindings::ParseChatMessageError; + +#[derive(Debug, thiserror::Error)] +pub enum ToolCallPipelineError { + #[error("tool-call pipeline invoked on empty buffer")] + EmptyBuffer, + #[error("bindings parse failed: {0}")] + Bindings(#[from] ParseChatMessageError), +} From c646c7de34bfa02797e768ba229089b9aaa8279d Mon Sep 17 00:00:00 2001 From: Mateusz Charytoniuk Date: Fri, 8 May 2026 18:01:47 +0200 Subject: [PATCH 21/51] Fix agent status snapshot dropping field updates after first change and add waiter wakeup tests --- Cargo.lock | 12 ++ Cargo.toml | 1 + paddler/Cargo.toml | 1 + paddler/src/balancer/agent_controller.rs | 114 +++++++++++++-- .../src/balancer/buffered_request_manager.rs | 136 ++++++++++++++++++ 5 files changed, 252 insertions(+), 12 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index ccda6015..4e0fb331 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4639,6 +4639,7 @@ dependencies = [ "thiserror 2.0.18", "tokio", "tokio-stream", + "tokio-test", "tokio-tungstenite", "tokio-util", "url", @@ -6582,6 +6583,17 @@ dependencies = [ "tokio-util", ] +[[package]] +name = "tokio-test" +version = "0.4.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f6d24790a10a7af737693a3e8f1d03faef7e6ca0cc99aae5066f533766de545" +dependencies = [ + "futures-core", + "tokio", + "tokio-stream", +] + [[package]] name = "tokio-tungstenite" version = "0.28.0" diff --git a/Cargo.toml b/Cargo.toml index 56261f19..5c5f0169 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -61,6 +61,7 @@ statum = "0.6" tempfile = "3.20.0" tokio = { version = "1.48", features = ["full"] } tokio-stream = { version = "0.1.17", features = ["sync"] } +tokio-test = "0.4.4" tokio-tungstenite = "0.28" tokio-util = "0.7" thiserror = "2" diff --git a/paddler/Cargo.toml b/paddler/Cargo.toml index 21f7ec99..603b3f6c 100644 --- a/paddler/Cargo.toml +++ b/paddler/Cargo.toml @@ -71,6 +71,7 @@ vulkan = ["llama-cpp-bindings/vulkan"] [dev-dependencies] tempfile = { workspace = true } +tokio-test = { workspace = true } [lints] workspace = true diff --git a/paddler/src/balancer/agent_controller.rs b/paddler/src/balancer/agent_controller.rs index c37c2b15..6b852af8 100644 --- a/paddler/src/balancer/agent_controller.rs +++ b/paddler/src/balancer/agent_controller.rs @@ -165,18 +165,16 @@ impl AgentController { let mut changed = false; - changed = changed || self.desired_slots_total.set_check(desired_slots_total); - changed = changed || self.download_current.set_check(download_current); - changed = changed || self.download_total.set_check(download_total); - changed = changed || self.slots_total.set_check(slots_total); - changed = changed - || self - .state_application_status_code - .set_check(state_application_status as i32); - changed = changed - || self - .uses_chat_template_override - .set_check(uses_chat_template_override); + changed |= self.desired_slots_total.set_check(desired_slots_total); + changed |= self.download_current.set_check(download_current); + changed |= self.download_total.set_check(download_total); + changed |= self.slots_total.set_check(slots_total); + changed |= self + .state_application_status_code + .set_check(state_application_status as i32); + changed |= self + .uses_chat_template_override + .set_check(uses_chat_template_override); self.newest_update_version .compare_and_swap(newest_update_version, version); @@ -348,3 +346,95 @@ impl SetsDesiredState for AgentController { .await } } + +#[cfg(test)] +mod tests { + use paddler_types::agent_state_application_status::AgentStateApplicationStatus; + + use super::*; + + fn fresh_agent_controller() -> AgentController { + let (agent_message_tx, _agent_message_rx) = mpsc::unbounded_channel(); + + AgentController { + agent_message_tx, + chat_template_override_sender_collection: Arc::new( + ChatTemplateOverrideSenderCollection::default(), + ), + connection_close: CancellationToken::new(), + desired_slots_total: AtomicValue::::new(0), + download_current: AtomicValue::::new(0), + download_filename: RwLock::new(None), + download_total: AtomicValue::::new(0), + embedding_sender_collection: Arc::new(EmbeddingSenderCollection::default()), + generate_tokens_sender_collection: Arc::new(GenerateTokensSenderCollection::default()), + id: "agent-test".to_owned(), + issues: RwLock::new(BTreeSet::new()), + model_metadata_sender_collection: Arc::new(ModelMetadataSenderCollection::default()), + model_path: RwLock::new(None), + name: None, + newest_update_version: AtomicValue::::new(0), + slots_processing: AtomicValue::::new(0), + slots_total: AtomicValue::::new(0), + state_application_status_code: AtomicValue::::new( + AgentStateApplicationStatus::Fresh as i32, + ), + uses_chat_template_override: AtomicValue::::new(false), + } + } + + #[test] + fn multi_field_update_stores_all_changed_atomic_fields() -> Result<()> { + let agent_controller = fresh_agent_controller(); + + let snapshot = SlotAggregatedStatusSnapshot { + desired_slots_total: 4, + download_current: 10, + download_filename: None, + download_total: 100, + issues: BTreeSet::new(), + model_path: None, + slots_processing: 0, + slots_total: 4, + state_application_status: AgentStateApplicationStatus::Fresh, + uses_chat_template_override: true, + version: 1, + }; + + let result = agent_controller.update_from_slot_aggregated_status_snapshot(snapshot); + + if !matches!(result, AgentControllerUpdateResult::Updated) { + anyhow::bail!("update with multiple changed fields must return Updated"); + } + + if agent_controller.desired_slots_total.get() != 4 { + anyhow::bail!( + "desired_slots_total must be stored: expected 4, got {}", + agent_controller.desired_slots_total.get() + ); + } + if agent_controller.download_current.get() != 10 { + anyhow::bail!( + "download_current must be stored: expected 10, got {}", + agent_controller.download_current.get() + ); + } + if agent_controller.download_total.get() != 100 { + anyhow::bail!( + "download_total must be stored: expected 100, got {}", + agent_controller.download_total.get() + ); + } + if agent_controller.slots_total.get() != 4 { + anyhow::bail!( + "slots_total must be stored: expected 4, got {}", + agent_controller.slots_total.get() + ); + } + if !agent_controller.uses_chat_template_override.get() { + anyhow::bail!("uses_chat_template_override must be stored: expected true, got false"); + } + + Ok(()) + } +} diff --git a/paddler/src/balancer/buffered_request_manager.rs b/paddler/src/balancer/buffered_request_manager.rs index c02d935f..13c9d74b 100644 --- a/paddler/src/balancer/buffered_request_manager.rs +++ b/paddler/src/balancer/buffered_request_manager.rs @@ -96,7 +96,25 @@ impl SubscribesToUpdates for BufferedRequestManager { #[cfg(test)] mod tests { + use std::collections::BTreeSet; + use std::sync::RwLock; + use std::sync::atomic::AtomicBool; + use std::sync::atomic::AtomicI32; + use std::sync::atomic::AtomicUsize; + use std::task::Poll; + + use paddler_types::agent_state_application_status::AgentStateApplicationStatus; + use tokio::sync::mpsc; + use tokio_util::sync::CancellationToken; + use super::*; + use crate::atomic_value::AtomicValue; + use crate::balancer::agent_controller::AgentController; + use crate::balancer::buffered_request_agent_wait_result::BufferedRequestAgentWaitResult; + use crate::balancer::chat_template_override_sender_collection::ChatTemplateOverrideSenderCollection; + use crate::balancer::embedding_sender_collection::EmbeddingSenderCollection; + use crate::balancer::generate_tokens_sender_collection::GenerateTokensSenderCollection; + use crate::balancer::model_metadata_sender_collection::ModelMetadataSenderCollection; #[tokio::test] async fn counter_increment_wakes_subscribed_waiter() -> Result<()> { @@ -118,4 +136,122 @@ mod tests { Ok(()) } + + #[tokio::test(flavor = "current_thread")] + async fn waiter_returns_found_after_agent_registration_with_no_initial_agents() -> Result<()> + { + let pool = Arc::new(AgentControllerPool::default()); + let manager = Arc::new(BufferedRequestManager::new( + pool.clone(), + Duration::from_secs(60), + 10, + )); + + let mut waiter = + tokio_test::task::spawn(async move { manager.wait_for_available_agent().await }); + + assert!( + waiter.poll().is_pending(), + "waiter must be Pending while pool has no agents" + ); + + let (agent_message_tx, _agent_message_rx) = mpsc::unbounded_channel(); + let agent = Arc::new(AgentController { + agent_message_tx, + chat_template_override_sender_collection: Arc::new( + ChatTemplateOverrideSenderCollection::default(), + ), + connection_close: CancellationToken::new(), + desired_slots_total: AtomicValue::::new(1), + download_current: AtomicValue::::new(0), + download_filename: RwLock::new(None), + download_total: AtomicValue::::new(0), + embedding_sender_collection: Arc::new(EmbeddingSenderCollection::default()), + generate_tokens_sender_collection: Arc::new(GenerateTokensSenderCollection::default()), + id: "agent-1".to_owned(), + issues: RwLock::new(BTreeSet::new()), + model_metadata_sender_collection: Arc::new(ModelMetadataSenderCollection::default()), + model_path: RwLock::new(None), + name: None, + newest_update_version: AtomicValue::::new(0), + slots_processing: AtomicValue::::new(0), + slots_total: AtomicValue::::new(1), + state_application_status_code: AtomicValue::::new( + AgentStateApplicationStatus::Fresh as i32, + ), + uses_chat_template_override: AtomicValue::::new(false), + }); + + pool.register_agent_controller("agent-1".to_owned(), agent)?; + + assert!( + waiter.is_woken(), + "register_agent_controller must wake the subscribed waiter" + ); + + let Poll::Ready(result) = waiter.poll() else { + anyhow::bail!("waiter must be Ready after register_agent_controller, got Pending"); + }; + + if !matches!(result?, BufferedRequestAgentWaitResult::Found(_)) { + anyhow::bail!("waiter must return Found after register_agent_controller"); + } + + Ok(()) + } + + #[tokio::test(flavor = "current_thread")] + async fn waiter_returns_found_when_agent_was_registered_before_call() -> Result<()> { + let pool = Arc::new(AgentControllerPool::default()); + + let (agent_message_tx, _agent_message_rx) = mpsc::unbounded_channel(); + let agent = Arc::new(AgentController { + agent_message_tx, + chat_template_override_sender_collection: Arc::new( + ChatTemplateOverrideSenderCollection::default(), + ), + connection_close: CancellationToken::new(), + desired_slots_total: AtomicValue::::new(1), + download_current: AtomicValue::::new(0), + download_filename: RwLock::new(None), + download_total: AtomicValue::::new(0), + embedding_sender_collection: Arc::new(EmbeddingSenderCollection::default()), + generate_tokens_sender_collection: Arc::new(GenerateTokensSenderCollection::default()), + id: "agent-pre".to_owned(), + issues: RwLock::new(BTreeSet::new()), + model_metadata_sender_collection: Arc::new(ModelMetadataSenderCollection::default()), + model_path: RwLock::new(None), + name: None, + newest_update_version: AtomicValue::::new(0), + slots_processing: AtomicValue::::new(0), + slots_total: AtomicValue::::new(1), + state_application_status_code: AtomicValue::::new( + AgentStateApplicationStatus::Fresh as i32, + ), + uses_chat_template_override: AtomicValue::::new(false), + }); + + pool.register_agent_controller("agent-pre".to_owned(), agent)?; + + let manager = Arc::new(BufferedRequestManager::new( + pool, + Duration::from_secs(60), + 10, + )); + + let mut waiter = + tokio_test::task::spawn(async move { manager.wait_for_available_agent().await }); + + let Poll::Ready(result) = waiter.poll() else { + anyhow::bail!( + "waiter must be Ready on first poll when agent was registered before call" + ); + }; + + if !matches!(result?, BufferedRequestAgentWaitResult::Found(_)) { + anyhow::bail!("waiter must return Found when an agent is already in the pool"); + } + + Ok(()) + } } From d579ba7817f62fb2a355f0cd9d89e575832a3e2c Mon Sep 17 00:00:00 2001 From: Mateusz Charytoniuk Date: Fri, 8 May 2026 18:01:57 +0200 Subject: [PATCH 22/51] Split chat_template_renderer into module and harden pyjinja_tojson with strict-whitelist kwargs --- .../mod.rs} | 56 ++++- .../chat_template_renderer/pyjinja_tojson.rs | 226 ++++++++++++++++++ .../chat_template_renderer/raise_exception.rs | 46 ++++ 3 files changed, 316 insertions(+), 12 deletions(-) rename paddler/src/{chat_template_renderer.rs => chat_template_renderer/mod.rs} (72%) create mode 100644 paddler/src/chat_template_renderer/pyjinja_tojson.rs create mode 100644 paddler/src/chat_template_renderer/raise_exception.rs diff --git a/paddler/src/chat_template_renderer.rs b/paddler/src/chat_template_renderer/mod.rs similarity index 72% rename from paddler/src/chat_template_renderer.rs rename to paddler/src/chat_template_renderer/mod.rs index 4f111c78..94f5445e 100644 --- a/paddler/src/chat_template_renderer.rs +++ b/paddler/src/chat_template_renderer/mod.rs @@ -1,21 +1,16 @@ +pub mod pyjinja_tojson; +pub mod raise_exception; + use anyhow::Result; use minijinja::Environment; -use minijinja::Error; -use minijinja::ErrorKind; use minijinja_contrib::pycompat::unknown_method_callback; use paddler_types::chat_template::ChatTemplate; use serde::ser::Serialize; -const CHAT_TEMPLATE_NAME: &str = "chat_template"; +use self::pyjinja_tojson::pyjinja_tojson; +use self::raise_exception::raise_exception; -// Known uses: -// https://huggingface.co/bartowski/Mistral-7B-Instruct-v0.3-GGUF -fn minijinja_raise_exception(message: &str) -> std::result::Result { - Err(Error::new::( - ErrorKind::InvalidOperation, - format!("Model's chat template raised an exception: '{message}'"), - )) -} +const CHAT_TEMPLATE_NAME: &str = "chat_template"; pub struct ChatTemplateRenderer { minijinja_env: Environment<'static>, @@ -25,11 +20,12 @@ impl ChatTemplateRenderer { pub fn new(ChatTemplate { content }: ChatTemplate) -> Result { let mut minijinja_env = Environment::new(); - minijinja_env.add_function("raise_exception", minijinja_raise_exception); + minijinja_env.add_function("raise_exception", raise_exception); minijinja_env.add_template_owned(CHAT_TEMPLATE_NAME, content)?; minijinja_env.set_unknown_method_callback(unknown_method_callback); minijinja_contrib::add_to_environment(&mut minijinja_env); + minijinja_env.add_filter("tojson", pyjinja_tojson); Ok(Self { minijinja_env }) } @@ -127,4 +123,40 @@ mod tests { Ok(()) } + + #[test] + fn registers_pyjinja_tojson_filter() -> Result<()> { + let template = ChatTemplate { + content: "{{ value | tojson(ensure_ascii=False) }}".to_owned(), + }; + let renderer = ChatTemplateRenderer::new(template)?; + + let result = renderer.render(context! { value => "café" })?; + + assert_eq!(result, "\"café\""); + + Ok(()) + } + + #[test] + fn registers_raise_exception_function() -> Result<()> { + let template = ChatTemplate { + content: "{{ raise_exception('boom') }}".to_owned(), + }; + let template_renderer = ChatTemplateRenderer::new(template)?; + + let err = template_renderer + .render(context! {}) + .err() + .ok_or_else(|| anyhow::anyhow!("expected Err, got Ok"))?; + let error_message = err.to_string(); + + if !error_message.contains("boom") { + return Err(anyhow::anyhow!( + "raise_exception must surface its message; got: {error_message}" + )); + } + + Ok(()) + } } diff --git a/paddler/src/chat_template_renderer/pyjinja_tojson.rs b/paddler/src/chat_template_renderer/pyjinja_tojson.rs new file mode 100644 index 00000000..71e61276 --- /dev/null +++ b/paddler/src/chat_template_renderer/pyjinja_tojson.rs @@ -0,0 +1,226 @@ +use minijinja::Error; +use minijinja::ErrorKind; +use minijinja::Value; +use minijinja::filters::tojson; +use minijinja::value::Kwargs; + +// Python-style `tojson` filter compatible with HuggingFace transformers chat +// templates that pass Jinja2 kwargs (`ensure_ascii`, `sort_keys`, `separators`, +// `indent`). minijinja's built-in `tojson` only accepts `indent`, so any of the +// others crashes rendering with "too many arguments". This wrapper: +// +// 1. Recognises every Python `tojson` kwarg explicitly (whitelist). +// 2. Accepts only values whose semantics match minijinja's defaults +// (`ensure_ascii=False`, `sort_keys=False`); rejects anything else with a +// clear error so the template author knows to remove it. +// 3. Forwards `indent` plus an empty kwargs map to minijinja's built-in +// `tojson` for the actual JSON serialisation, so behaviour and output +// formatting stay identical. +// 4. Calls `Kwargs::assert_all_used` so unknown kwargs (anything not in our +// whitelist) hard-error rather than getting silently dropped. +#[expect( + clippy::needless_pass_by_value, + reason = "minijinja's Filter trait requires Kwargs by value; taking &Kwargs makes the \ + function unregisterable as a filter" +)] +pub fn pyjinja_tojson(value: &Value, kwargs: Kwargs) -> Result { + let indent: Option = kwargs.get("indent")?; + + let ensure_ascii: Option = kwargs.get("ensure_ascii")?; + if matches!(ensure_ascii, Some(true)) { + return Err(Error::new( + ErrorKind::InvalidOperation, + "tojson(ensure_ascii=True) is not supported by minijinja: object output already \ + emits non-ASCII characters unescaped (matching ensure_ascii=False). Drop the \ + kwarg or set it to False.", + )); + } + + let sort_keys: Option = kwargs.get("sort_keys")?; + if matches!(sort_keys, Some(true)) { + return Err(Error::new( + ErrorKind::InvalidOperation, + "tojson(sort_keys=True) is not supported by minijinja: object key ordering follows \ + insertion order. Drop the kwarg or set it to False.", + )); + } + + let separators: Option = kwargs.get("separators")?; + if separators.is_some() { + return Err(Error::new( + ErrorKind::InvalidOperation, + "tojson(separators=...) is not supported by minijinja: separator strings are fixed.", + )); + } + + kwargs.assert_all_used()?; + + let forwarded_kwargs: Kwargs = Kwargs::from_iter(Vec::<(String, Value)>::new()); + + tojson(value, indent, forwarded_kwargs) +} + +#[cfg(test)] +mod tests { + use anyhow::Result; + use anyhow::anyhow; + use minijinja::Environment; + use minijinja::context; + + use super::pyjinja_tojson; + + fn render(template_source: &str, scope: minijinja::Value) -> Result { + let mut env = Environment::new(); + env.add_filter("tojson", pyjinja_tojson); + env.add_template_owned("t", template_source.to_owned())?; + Ok(env.get_template("t")?.render(scope)?) + } + + fn render_expecting_error( + template_source: &str, + scope: minijinja::Value, + ) -> Result { + let mut env = Environment::new(); + env.add_filter("tojson", pyjinja_tojson); + env.add_template_owned("t", template_source.to_owned())?; + let outcome = env.get_template("t")?.render(scope); + + outcome.err().ok_or_else(|| anyhow!("expected Err, got Ok")) + } + + #[test] + fn no_kwargs_emits_quoted_json_string() -> Result<()> { + let result = render("{{ value | tojson }}", context! { value => "hello" })?; + + assert_eq!(result, "\"hello\""); + + Ok(()) + } + + #[test] + fn ensure_ascii_false_matches_default_output() -> Result<()> { + let with_kwarg = render( + "{{ value | tojson(ensure_ascii=False) }}", + context! { value => "café" }, + )?; + let without_kwarg = render("{{ value | tojson }}", context! { value => "café" })?; + + assert_eq!(with_kwarg, without_kwarg); + assert_eq!(with_kwarg, "\"café\""); + + Ok(()) + } + + #[test] + fn ensure_ascii_true_returns_error_naming_the_kwarg() -> Result<()> { + let err = render_expecting_error( + "{{ value | tojson(ensure_ascii=True) }}", + context! { value => "x" }, + )?; + let rendered = err.to_string(); + + if !rendered.contains("ensure_ascii=True") { + return Err(anyhow!( + "error must name the rejected kwarg; got: {rendered}" + )); + } + + Ok(()) + } + + #[test] + fn sort_keys_false_matches_default_output() -> Result<()> { + let with_kwarg = render( + "{{ value | tojson(sort_keys=False) }}", + context! { value => "x" }, + )?; + + assert_eq!(with_kwarg, "\"x\""); + + Ok(()) + } + + #[test] + fn sort_keys_true_returns_error_naming_the_kwarg() -> Result<()> { + let err = render_expecting_error( + "{{ value | tojson(sort_keys=True) }}", + context! { value => "x" }, + )?; + let rendered = err.to_string(); + + if !rendered.contains("sort_keys=True") { + return Err(anyhow!( + "error must name the rejected kwarg; got: {rendered}" + )); + } + + Ok(()) + } + + #[test] + fn separators_returns_error_naming_the_kwarg() -> Result<()> { + let err = render_expecting_error( + "{{ value | tojson(separators=[',', ':']) }}", + context! { value => "x" }, + )?; + let rendered = err.to_string(); + + if !rendered.contains("separators") { + return Err(anyhow!( + "error must name the rejected kwarg; got: {rendered}" + )); + } + + Ok(()) + } + + #[test] + fn indent_kwarg_emits_pretty_printed_json() -> Result<()> { + let result = render( + "{{ value | tojson(indent=2) }}", + context! { value => context! { k => "v" } }, + )?; + + assert_eq!(result, "{\n \"k\": \"v\"\n}"); + + Ok(()) + } + + #[test] + fn indent_kwarg_combines_with_ensure_ascii_false() -> Result<()> { + let result = render( + "{{ value | tojson(ensure_ascii=False, indent=2) }}", + context! { value => context! { k => "café" } }, + )?; + + assert_eq!(result, "{\n \"k\": \"café\"\n}"); + + Ok(()) + } + + #[test] + fn unknown_kwarg_returns_error() -> Result<()> { + let err = render_expecting_error( + "{{ value | tojson(bogus=42) }}", + context! { value => "x" }, + )?; + let rendered = err.to_string(); + + if !rendered.contains("bogus") { + return Err(anyhow!( + "error must name the unknown kwarg; got: {rendered}" + )); + } + + Ok(()) + } + + #[test] + fn non_ascii_codepoints_emitted_unescaped() -> Result<()> { + let result = render("{{ value | tojson }}", context! { value => "日本語" })?; + + assert_eq!(result, "\"日本語\""); + + Ok(()) + } +} diff --git a/paddler/src/chat_template_renderer/raise_exception.rs b/paddler/src/chat_template_renderer/raise_exception.rs new file mode 100644 index 00000000..30a4efe4 --- /dev/null +++ b/paddler/src/chat_template_renderer/raise_exception.rs @@ -0,0 +1,46 @@ +use minijinja::Error; +use minijinja::ErrorKind; + +// Surfaces errors raised explicitly inside a chat template. Known uses: +// https://huggingface.co/bartowski/Mistral-7B-Instruct-v0.3-GGUF +pub fn raise_exception(message: &str) -> Result { + Err(Error::new::( + ErrorKind::InvalidOperation, + format!("Model's chat template raised an exception: '{message}'"), + )) +} + +#[cfg(test)] +mod tests { + use anyhow::Result; + use anyhow::anyhow; + + use super::raise_exception; + + #[test] + fn returns_err_with_supplied_message_quoted() -> Result<()> { + let err = raise_exception("template is invalid") + .err() + .ok_or_else(|| anyhow!("expected Err, got Ok"))?; + let rendered = err.to_string(); + + if !rendered.contains("template is invalid") { + return Err(anyhow!( + "error must include the supplied message; got: {rendered}" + )); + } + + Ok(()) + } + + #[test] + fn returns_err_with_invalid_operation_kind() -> Result<()> { + let err = raise_exception("anything") + .err() + .ok_or_else(|| anyhow!("expected Err, got Ok"))?; + + assert_eq!(err.kind(), minijinja::ErrorKind::InvalidOperation); + + Ok(()) + } +} From 46cf957e27de909baa38f9165d4dd97fbefdd50c Mon Sep 17 00:00:00 2001 From: Mateusz Charytoniuk Date: Fri, 8 May 2026 18:02:07 +0200 Subject: [PATCH 23/51] Add GLM-4.7-Flash and DeepSeek-R1-Distill-Llama-8B integration test coverage --- paddler_tests/src/lib.rs | 2 + .../deepseek_r1_distill_llama_8b.rs | 15 +++ paddler_tests/src/model_card/glm_4_7_flash.rs | 15 +++ paddler_tests/src/model_card/mod.rs | 2 + ...uster_with_deepseek_r1_distill_llama_8b.rs | 37 +++++++ ...t_in_process_cluster_with_glm_4_7_flash.rs | 37 +++++++ ...nternal_endpoint_emits_reasoning_tokens.rs | 102 ++++++++++++++++++ ...nternal_endpoint_emits_reasoning_tokens.rs | 102 ++++++++++++++++++ ...l_endpoint_emits_tool_call_parsed_event.rs | 92 ++++++++++++++++ 9 files changed, 404 insertions(+) create mode 100644 paddler_tests/src/model_card/deepseek_r1_distill_llama_8b.rs create mode 100644 paddler_tests/src/model_card/glm_4_7_flash.rs create mode 100644 paddler_tests/src/start_in_process_cluster_with_deepseek_r1_distill_llama_8b.rs create mode 100644 paddler_tests/src/start_in_process_cluster_with_glm_4_7_flash.rs create mode 100644 paddler_tests/tests/deepseek_r1_distill_llama_8b_internal_endpoint_emits_reasoning_tokens.rs create mode 100644 paddler_tests/tests/glm_4_7_flash_internal_endpoint_emits_reasoning_tokens.rs create mode 100644 paddler_tests/tests/glm_4_7_flash_internal_endpoint_emits_tool_call_parsed_event.rs diff --git a/paddler_tests/src/lib.rs b/paddler_tests/src/lib.rs index 3f4c32e2..0a8ff9ea 100644 --- a/paddler_tests/src/lib.rs +++ b/paddler_tests/src/lib.rs @@ -23,7 +23,9 @@ pub mod parse_test_device_value; pub mod spawn_agent_subprocess; pub mod spawn_agent_subprocess_params; pub mod start_in_process_cluster; +pub mod start_in_process_cluster_with_deepseek_r1_distill_llama_8b; pub mod start_in_process_cluster_with_gemma_4; +pub mod start_in_process_cluster_with_glm_4_7_flash; pub mod start_in_process_cluster_with_ministral_3; pub mod start_in_process_cluster_with_qwen2_5_vl; pub mod start_in_process_cluster_with_qwen3; diff --git a/paddler_tests/src/model_card/deepseek_r1_distill_llama_8b.rs b/paddler_tests/src/model_card/deepseek_r1_distill_llama_8b.rs new file mode 100644 index 00000000..993f116b --- /dev/null +++ b/paddler_tests/src/model_card/deepseek_r1_distill_llama_8b.rs @@ -0,0 +1,15 @@ +use paddler_types::huggingface_model_reference::HuggingFaceModelReference; + +use crate::model_card::ModelCard; + +#[must_use] +pub fn deepseek_r1_distill_llama_8b() -> ModelCard { + ModelCard { + gpu_layer_count: 999, + reference: HuggingFaceModelReference { + filename: "DeepSeek-R1-Distill-Llama-8B-Q4_K_M.gguf".to_owned(), + repo_id: "unsloth/DeepSeek-R1-Distill-Llama-8B-GGUF".to_owned(), + revision: "main".to_owned(), + }, + } +} diff --git a/paddler_tests/src/model_card/glm_4_7_flash.rs b/paddler_tests/src/model_card/glm_4_7_flash.rs new file mode 100644 index 00000000..5d5bba3e --- /dev/null +++ b/paddler_tests/src/model_card/glm_4_7_flash.rs @@ -0,0 +1,15 @@ +use paddler_types::huggingface_model_reference::HuggingFaceModelReference; + +use crate::model_card::ModelCard; + +#[must_use] +pub fn glm_4_7_flash() -> ModelCard { + ModelCard { + gpu_layer_count: 999, + reference: HuggingFaceModelReference { + filename: "GLM-4.7-Flash-Q4_K_M.gguf".to_owned(), + repo_id: "unsloth/GLM-4.7-Flash-GGUF".to_owned(), + revision: "main".to_owned(), + }, + } +} diff --git a/paddler_tests/src/model_card/mod.rs b/paddler_tests/src/model_card/mod.rs index fdc22761..37734617 100644 --- a/paddler_tests/src/model_card/mod.rs +++ b/paddler_tests/src/model_card/mod.rs @@ -1,4 +1,6 @@ +pub mod deepseek_r1_distill_llama_8b; pub mod gemma_4_e4b_it; +pub mod glm_4_7_flash; pub mod ministral_3_14b_reasoning; pub mod nomic_embed_text_v1_5; pub mod qwen2_5_vl_3b; diff --git a/paddler_tests/src/start_in_process_cluster_with_deepseek_r1_distill_llama_8b.rs b/paddler_tests/src/start_in_process_cluster_with_deepseek_r1_distill_llama_8b.rs new file mode 100644 index 00000000..7d010c39 --- /dev/null +++ b/paddler_tests/src/start_in_process_cluster_with_deepseek_r1_distill_llama_8b.rs @@ -0,0 +1,37 @@ +use anyhow::Result; +use paddler_types::agent_desired_model::AgentDesiredModel; +use paddler_types::balancer_desired_state::BalancerDesiredState; + +use crate::cluster_handle::ClusterHandle; +use crate::current_test_device::current_test_device; +use crate::in_process_cluster_params::InProcessClusterParams; +use crate::model_card::ModelCard; +use crate::model_card::deepseek_r1_distill_llama_8b::deepseek_r1_distill_llama_8b; +use crate::start_in_process_cluster::start_in_process_cluster; + +pub async fn start_in_process_cluster_with_deepseek_r1_distill_llama_8b( + slots_per_agent: i32, +) -> Result { + let device = current_test_device()?; + + device.require_available()?; + + let ModelCard { + gpu_layer_count, + reference, + } = deepseek_r1_distill_llama_8b(); + + start_in_process_cluster(InProcessClusterParams { + slots_per_agent, + desired_state: BalancerDesiredState { + chat_template_override: None, + inference_parameters: device.inference_parameters_for_full_offload(gpu_layer_count), + model: AgentDesiredModel::HuggingFace(reference), + multimodal_projection: AgentDesiredModel::None, + use_chat_template_override: false, + }, + wait_for_slots_ready: true, + ..InProcessClusterParams::default() + }) + .await +} diff --git a/paddler_tests/src/start_in_process_cluster_with_glm_4_7_flash.rs b/paddler_tests/src/start_in_process_cluster_with_glm_4_7_flash.rs new file mode 100644 index 00000000..24ddbd7a --- /dev/null +++ b/paddler_tests/src/start_in_process_cluster_with_glm_4_7_flash.rs @@ -0,0 +1,37 @@ +use anyhow::Result; +use paddler_types::agent_desired_model::AgentDesiredModel; +use paddler_types::balancer_desired_state::BalancerDesiredState; + +use crate::cluster_handle::ClusterHandle; +use crate::current_test_device::current_test_device; +use crate::in_process_cluster_params::InProcessClusterParams; +use crate::model_card::ModelCard; +use crate::model_card::glm_4_7_flash::glm_4_7_flash; +use crate::start_in_process_cluster::start_in_process_cluster; + +pub async fn start_in_process_cluster_with_glm_4_7_flash( + slots_per_agent: i32, +) -> Result { + let device = current_test_device()?; + + device.require_available()?; + + let ModelCard { + gpu_layer_count, + reference, + } = glm_4_7_flash(); + + start_in_process_cluster(InProcessClusterParams { + slots_per_agent, + desired_state: BalancerDesiredState { + chat_template_override: None, + inference_parameters: device.inference_parameters_for_full_offload(gpu_layer_count), + model: AgentDesiredModel::HuggingFace(reference), + multimodal_projection: AgentDesiredModel::None, + use_chat_template_override: false, + }, + wait_for_slots_ready: true, + ..InProcessClusterParams::default() + }) + .await +} diff --git a/paddler_tests/tests/deepseek_r1_distill_llama_8b_internal_endpoint_emits_reasoning_tokens.rs b/paddler_tests/tests/deepseek_r1_distill_llama_8b_internal_endpoint_emits_reasoning_tokens.rs new file mode 100644 index 00000000..ebdc17f7 --- /dev/null +++ b/paddler_tests/tests/deepseek_r1_distill_llama_8b_internal_endpoint_emits_reasoning_tokens.rs @@ -0,0 +1,102 @@ +#![cfg(feature = "tests_that_use_llms")] + +use anyhow::Result; +use paddler_tests::collect_generated_tokens::collect_generated_tokens; +use paddler_tests::inference_http_client::InferenceHttpClient; +use paddler_tests::start_in_process_cluster_with_deepseek_r1_distill_llama_8b::start_in_process_cluster_with_deepseek_r1_distill_llama_8b; +use paddler_types::conversation_history::ConversationHistory; +use paddler_types::conversation_message::ConversationMessage; +use paddler_types::conversation_message_content::ConversationMessageContent; +use paddler_types::generated_token_result::GeneratedTokenResult; +use paddler_types::request_params::continue_from_conversation_history_params::ContinueFromConversationHistoryParams; +use reqwest::Client; + +#[serial_test::file_serial(model_load, path => "../target/model_load.lock")] +#[tokio::test(flavor = "multi_thread")] +async fn deepseek_r1_distill_llama_8b_internal_endpoint_emits_reasoning_tokens() -> Result<()> { + let cluster = start_in_process_cluster_with_deepseek_r1_distill_llama_8b(1).await?; + + let inference_client = + InferenceHttpClient::new(Client::new(), cluster.addresses.inference_base_url()?); + + let stream = inference_client + .post_continue_from_conversation_history(&ContinueFromConversationHistoryParams { + add_generation_prompt: true, + conversation_history: ConversationHistory::new(vec![ConversationMessage { + content: ConversationMessageContent::Text( + "What is two plus two? Think step by step.".to_owned(), + ), + role: "user".to_owned(), + }]), + enable_thinking: true, + grammar: None, + max_tokens: 400, + parse_tool_calls: false, + tools: vec![], + }) + .await?; + + let collected = collect_generated_tokens(stream).await?; + + let reasoning_count = collected + .token_results + .iter() + .filter(|result| matches!(result, GeneratedTokenResult::ReasoningToken(_))) + .count(); + + assert!( + reasoning_count > 0, + "DeepSeek-R1-8B: expected at least one reasoning token from a `` block (got {reasoning_count})" + ); + + let last = collected + .token_results + .last() + .ok_or_else(|| anyhow::anyhow!("no token results received"))?; + let GeneratedTokenResult::Done(summary) = last else { + anyhow::bail!("last result was not Done: {last:?}"); + }; + + assert!(summary.usage.prompt_tokens > 0); + assert!(summary.usage.reasoning_tokens > 0); + assert_eq!( + summary.usage.completion_tokens(), + summary.usage.content_tokens + + summary.usage.reasoning_tokens + + summary.usage.undeterminable_tokens + ); + + let reasoning_stream: String = collected + .token_results + .iter() + .filter_map(|result| match result { + GeneratedTokenResult::ReasoningToken(piece) => Some(piece.as_str()), + _ => None, + }) + .collect(); + let content_stream: String = collected + .token_results + .iter() + .filter_map(|result| match result { + GeneratedTokenResult::ContentToken(piece) => Some(piece.as_str()), + _ => None, + }) + .collect(); + + for forbidden in ["", ""] { + assert!( + !reasoning_stream.contains(forbidden), + "DeepSeek-R1-8B: reasoning stream leaked marker {forbidden:?}; \ + reasoning_stream={reasoning_stream:?}" + ); + assert!( + !content_stream.contains(forbidden), + "DeepSeek-R1-8B: content stream leaked marker {forbidden:?}; \ + content_stream={content_stream:?}" + ); + } + + cluster.shutdown().await?; + + Ok(()) +} diff --git a/paddler_tests/tests/glm_4_7_flash_internal_endpoint_emits_reasoning_tokens.rs b/paddler_tests/tests/glm_4_7_flash_internal_endpoint_emits_reasoning_tokens.rs new file mode 100644 index 00000000..dfdd3a33 --- /dev/null +++ b/paddler_tests/tests/glm_4_7_flash_internal_endpoint_emits_reasoning_tokens.rs @@ -0,0 +1,102 @@ +#![cfg(feature = "tests_that_use_llms")] + +use anyhow::Result; +use paddler_tests::collect_generated_tokens::collect_generated_tokens; +use paddler_tests::inference_http_client::InferenceHttpClient; +use paddler_tests::start_in_process_cluster_with_glm_4_7_flash::start_in_process_cluster_with_glm_4_7_flash; +use paddler_types::conversation_history::ConversationHistory; +use paddler_types::conversation_message::ConversationMessage; +use paddler_types::conversation_message_content::ConversationMessageContent; +use paddler_types::generated_token_result::GeneratedTokenResult; +use paddler_types::request_params::continue_from_conversation_history_params::ContinueFromConversationHistoryParams; +use reqwest::Client; + +#[serial_test::file_serial(model_load, path => "../target/model_load.lock")] +#[tokio::test(flavor = "multi_thread")] +async fn glm_4_7_flash_internal_endpoint_emits_reasoning_tokens() -> Result<()> { + let cluster = start_in_process_cluster_with_glm_4_7_flash(1).await?; + + let inference_client = + InferenceHttpClient::new(Client::new(), cluster.addresses.inference_base_url()?); + + let stream = inference_client + .post_continue_from_conversation_history(&ContinueFromConversationHistoryParams { + add_generation_prompt: true, + conversation_history: ConversationHistory::new(vec![ConversationMessage { + content: ConversationMessageContent::Text( + "What is two plus two? Think step by step.".to_owned(), + ), + role: "user".to_owned(), + }]), + enable_thinking: true, + grammar: None, + max_tokens: 400, + parse_tool_calls: false, + tools: vec![], + }) + .await?; + + let collected = collect_generated_tokens(stream).await?; + + let reasoning_count = collected + .token_results + .iter() + .filter(|result| matches!(result, GeneratedTokenResult::ReasoningToken(_))) + .count(); + + assert!( + reasoning_count > 0, + "GLM-4.7: expected at least one reasoning token from a `` block (got {reasoning_count})" + ); + + let last = collected + .token_results + .last() + .ok_or_else(|| anyhow::anyhow!("no token results received"))?; + let GeneratedTokenResult::Done(summary) = last else { + anyhow::bail!("last result was not Done: {last:?}"); + }; + + assert!(summary.usage.prompt_tokens > 0); + assert!(summary.usage.reasoning_tokens > 0); + assert_eq!( + summary.usage.completion_tokens(), + summary.usage.content_tokens + + summary.usage.reasoning_tokens + + summary.usage.undeterminable_tokens + ); + + let reasoning_stream: String = collected + .token_results + .iter() + .filter_map(|result| match result { + GeneratedTokenResult::ReasoningToken(piece) => Some(piece.as_str()), + _ => None, + }) + .collect(); + let content_stream: String = collected + .token_results + .iter() + .filter_map(|result| match result { + GeneratedTokenResult::ContentToken(piece) => Some(piece.as_str()), + _ => None, + }) + .collect(); + + for forbidden in ["", ""] { + assert!( + !reasoning_stream.contains(forbidden), + "GLM-4.7: reasoning stream leaked marker {forbidden:?}; \ + reasoning_stream={reasoning_stream:?}" + ); + assert!( + !content_stream.contains(forbidden), + "GLM-4.7: content stream leaked marker {forbidden:?}; \ + content_stream={content_stream:?}" + ); + } + + cluster.shutdown().await?; + + Ok(()) +} diff --git a/paddler_tests/tests/glm_4_7_flash_internal_endpoint_emits_tool_call_parsed_event.rs b/paddler_tests/tests/glm_4_7_flash_internal_endpoint_emits_tool_call_parsed_event.rs new file mode 100644 index 00000000..c071836b --- /dev/null +++ b/paddler_tests/tests/glm_4_7_flash_internal_endpoint_emits_tool_call_parsed_event.rs @@ -0,0 +1,92 @@ +#![cfg(feature = "tests_that_use_llms")] + +use anyhow::Result; +use paddler_tests::collect_generated_tokens::collect_generated_tokens; +use paddler_tests::inference_http_client::InferenceHttpClient; +use paddler_tests::start_in_process_cluster_with_glm_4_7_flash::start_in_process_cluster_with_glm_4_7_flash; +use paddler_types::conversation_history::ConversationHistory; +use paddler_types::conversation_message::ConversationMessage; +use paddler_types::conversation_message_content::ConversationMessageContent; +use paddler_types::generated_token_result::GeneratedTokenResult; +use paddler_types::request_params::continue_from_conversation_history_params::ContinueFromConversationHistoryParams; +use paddler_types::request_params::continue_from_conversation_history_params::tool::Tool; +use paddler_types::request_params::continue_from_conversation_history_params::tool::tool_params::function_call::FunctionCall; +use paddler_types::request_params::continue_from_conversation_history_params::tool::tool_params::function_call::function::Function; +use paddler_types::request_params::continue_from_conversation_history_params::tool::tool_params::function_call::parameters::Parameters; +use paddler_types::request_params::continue_from_conversation_history_params::tool::tool_params::function_call::parameters_schema::validated_parameters_schema::ValidatedParametersSchema; +use reqwest::Client; +use serde_json::Map; +use serde_json::Value; + +#[serial_test::file_serial(model_load, path => "../target/model_load.lock")] +#[tokio::test(flavor = "multi_thread")] +async fn glm_4_7_flash_internal_endpoint_emits_tool_call_parsed_event() -> Result<()> { + let cluster = start_in_process_cluster_with_glm_4_7_flash(1).await?; + + let inference_client = + InferenceHttpClient::new(Client::new(), cluster.addresses.inference_base_url()?); + + let mut location_properties = Map::new(); + location_properties.insert( + "location".to_owned(), + serde_json::json!({"type": "string", "description": "The city name"}), + ); + + let stream = inference_client + .post_continue_from_conversation_history(&ContinueFromConversationHistoryParams { + add_generation_prompt: true, + conversation_history: ConversationHistory::new(vec![ConversationMessage { + content: ConversationMessageContent::Text( + "What is the weather in Paris? Use the get_weather tool to find out." + .to_owned(), + ), + role: "user".to_owned(), + }]), + enable_thinking: true, + grammar: None, + max_tokens: 400, + parse_tool_calls: true, + tools: vec![Tool::Function(FunctionCall { + function: Function { + name: "get_weather".to_owned(), + description: "Get the current weather for a location".to_owned(), + parameters: Parameters::Schema(ValidatedParametersSchema { + schema_type: "object".to_owned(), + properties: Some(location_properties), + required: Some(vec!["location".to_owned()]), + additional_properties: Some(Value::Bool(false)), + }), + }, + })], + }) + .await?; + + let collected = collect_generated_tokens(stream).await?; + + let parsed_events: Vec<&Vec> = collected + .token_results + .iter() + .filter_map(|event| match event { + GeneratedTokenResult::ToolCallParsed(parsed) => Some(parsed), + _ => None, + }) + .collect(); + + assert!( + !parsed_events.is_empty(), + "GLM-4.7: expected at least one ToolCallParsed event; got tokens:\n{}", + collected.text + ); + + let first_call = parsed_events + .iter() + .flat_map(|calls| calls.iter()) + .next() + .ok_or_else(|| anyhow::anyhow!("no parsed tool calls in any event"))?; + + assert_eq!(first_call.name, "get_weather"); + + cluster.shutdown().await?; + + Ok(()) +} From e2871dabaa76a6f97264ce2233f2e66a85776755 Mon Sep 17 00:00:00 2001 From: Mateusz Charytoniuk Date: Fri, 8 May 2026 21:08:58 +0200 Subject: [PATCH 24/51] Add per-model integration tests covering image-attached requests with reasoning enabled --- paddler_tests/src/lib.rs | 3 + .../src/model_card/gemma_4_e4b_it_mmproj.rs | 15 ++++ .../ministral_3_14b_reasoning_mmproj.rs | 15 ++++ paddler_tests/src/model_card/mod.rs | 3 + .../src/model_card/qwen3_6_35b_a3b_mmproj.rs | 15 ++++ ...process_cluster_with_gemma_4_and_mmproj.rs | 42 ++++++++++ ...ess_cluster_with_ministral_3_and_mmproj.rs | 42 ++++++++++ ...process_cluster_with_qwen3_6_and_mmproj.rs | 42 ++++++++++ ...mits_reasoning_tokens_for_image_request.rs | 80 +++++++++++++++++++ ...mits_reasoning_tokens_for_image_request.rs | 80 +++++++++++++++++++ ...mits_reasoning_tokens_for_image_request.rs | 80 +++++++++++++++++++ ...mits_reasoning_tokens_for_image_request.rs | 80 +++++++++++++++++++ 12 files changed, 497 insertions(+) create mode 100644 paddler_tests/src/model_card/gemma_4_e4b_it_mmproj.rs create mode 100644 paddler_tests/src/model_card/ministral_3_14b_reasoning_mmproj.rs create mode 100644 paddler_tests/src/model_card/qwen3_6_35b_a3b_mmproj.rs create mode 100644 paddler_tests/src/start_in_process_cluster_with_gemma_4_and_mmproj.rs create mode 100644 paddler_tests/src/start_in_process_cluster_with_ministral_3_and_mmproj.rs create mode 100644 paddler_tests/src/start_in_process_cluster_with_qwen3_6_and_mmproj.rs create mode 100644 paddler_tests/tests/gemma4_internal_endpoint_emits_reasoning_tokens_for_image_request.rs create mode 100644 paddler_tests/tests/mistral3_internal_endpoint_emits_reasoning_tokens_for_image_request.rs create mode 100644 paddler_tests/tests/qwen35_internal_endpoint_emits_reasoning_tokens_for_image_request.rs create mode 100644 paddler_tests/tests/qwen36_internal_endpoint_emits_reasoning_tokens_for_image_request.rs diff --git a/paddler_tests/src/lib.rs b/paddler_tests/src/lib.rs index 0a8ff9ea..ec29a9dd 100644 --- a/paddler_tests/src/lib.rs +++ b/paddler_tests/src/lib.rs @@ -25,12 +25,15 @@ pub mod spawn_agent_subprocess_params; pub mod start_in_process_cluster; pub mod start_in_process_cluster_with_deepseek_r1_distill_llama_8b; pub mod start_in_process_cluster_with_gemma_4; +pub mod start_in_process_cluster_with_gemma_4_and_mmproj; pub mod start_in_process_cluster_with_glm_4_7_flash; pub mod start_in_process_cluster_with_ministral_3; +pub mod start_in_process_cluster_with_ministral_3_and_mmproj; pub mod start_in_process_cluster_with_qwen2_5_vl; pub mod start_in_process_cluster_with_qwen3; pub mod start_in_process_cluster_with_qwen3_5; pub mod start_in_process_cluster_with_qwen3_6; +pub mod start_in_process_cluster_with_qwen3_6_and_mmproj; pub mod start_in_process_cluster_with_smolvlm2; pub mod start_in_process_embedding_cluster; pub mod start_subprocess_cluster; diff --git a/paddler_tests/src/model_card/gemma_4_e4b_it_mmproj.rs b/paddler_tests/src/model_card/gemma_4_e4b_it_mmproj.rs new file mode 100644 index 00000000..083db911 --- /dev/null +++ b/paddler_tests/src/model_card/gemma_4_e4b_it_mmproj.rs @@ -0,0 +1,15 @@ +use paddler_types::huggingface_model_reference::HuggingFaceModelReference; + +use crate::model_card::ModelCard; + +#[must_use] +pub fn gemma_4_e4b_it_mmproj() -> ModelCard { + ModelCard { + gpu_layer_count: 999, + reference: HuggingFaceModelReference { + filename: "mmproj-F16.gguf".to_owned(), + repo_id: "unsloth/gemma-4-E4B-it-GGUF".to_owned(), + revision: "main".to_owned(), + }, + } +} diff --git a/paddler_tests/src/model_card/ministral_3_14b_reasoning_mmproj.rs b/paddler_tests/src/model_card/ministral_3_14b_reasoning_mmproj.rs new file mode 100644 index 00000000..be0c5b76 --- /dev/null +++ b/paddler_tests/src/model_card/ministral_3_14b_reasoning_mmproj.rs @@ -0,0 +1,15 @@ +use paddler_types::huggingface_model_reference::HuggingFaceModelReference; + +use crate::model_card::ModelCard; + +#[must_use] +pub fn ministral_3_14b_reasoning_mmproj() -> ModelCard { + ModelCard { + gpu_layer_count: 999, + reference: HuggingFaceModelReference { + filename: "mmproj-F16.gguf".to_owned(), + repo_id: "unsloth/Ministral-3-14B-Reasoning-2512-GGUF".to_owned(), + revision: "main".to_owned(), + }, + } +} diff --git a/paddler_tests/src/model_card/mod.rs b/paddler_tests/src/model_card/mod.rs index 37734617..dedd03a3 100644 --- a/paddler_tests/src/model_card/mod.rs +++ b/paddler_tests/src/model_card/mod.rs @@ -1,7 +1,9 @@ pub mod deepseek_r1_distill_llama_8b; pub mod gemma_4_e4b_it; +pub mod gemma_4_e4b_it_mmproj; pub mod glm_4_7_flash; pub mod ministral_3_14b_reasoning; +pub mod ministral_3_14b_reasoning_mmproj; pub mod nomic_embed_text_v1_5; pub mod qwen2_5_vl_3b; pub mod qwen2_5_vl_3b_mmproj; @@ -9,6 +11,7 @@ pub mod qwen3_0_6b; pub mod qwen3_5_0_8b; pub mod qwen3_5_0_8b_mmproj; pub mod qwen3_6_35b_a3b; +pub mod qwen3_6_35b_a3b_mmproj; pub mod qwen3_embedding_0_6b; pub mod smolvlm2_256m; pub mod smolvlm2_256m_mmproj; diff --git a/paddler_tests/src/model_card/qwen3_6_35b_a3b_mmproj.rs b/paddler_tests/src/model_card/qwen3_6_35b_a3b_mmproj.rs new file mode 100644 index 00000000..5d6a5b55 --- /dev/null +++ b/paddler_tests/src/model_card/qwen3_6_35b_a3b_mmproj.rs @@ -0,0 +1,15 @@ +use paddler_types::huggingface_model_reference::HuggingFaceModelReference; + +use crate::model_card::ModelCard; + +#[must_use] +pub fn qwen3_6_35b_a3b_mmproj() -> ModelCard { + ModelCard { + gpu_layer_count: 999, + reference: HuggingFaceModelReference { + filename: "mmproj-F16.gguf".to_owned(), + repo_id: "unsloth/Qwen3.6-35B-A3B-GGUF".to_owned(), + revision: "main".to_owned(), + }, + } +} diff --git a/paddler_tests/src/start_in_process_cluster_with_gemma_4_and_mmproj.rs b/paddler_tests/src/start_in_process_cluster_with_gemma_4_and_mmproj.rs new file mode 100644 index 00000000..41c37ab1 --- /dev/null +++ b/paddler_tests/src/start_in_process_cluster_with_gemma_4_and_mmproj.rs @@ -0,0 +1,42 @@ +use anyhow::Result; +use paddler_types::agent_desired_model::AgentDesiredModel; +use paddler_types::balancer_desired_state::BalancerDesiredState; + +use crate::cluster_handle::ClusterHandle; +use crate::current_test_device::current_test_device; +use crate::in_process_cluster_params::InProcessClusterParams; +use crate::model_card::ModelCard; +use crate::model_card::gemma_4_e4b_it::gemma_4_e4b_it; +use crate::model_card::gemma_4_e4b_it_mmproj::gemma_4_e4b_it_mmproj; +use crate::start_in_process_cluster::start_in_process_cluster; + +pub async fn start_in_process_cluster_with_gemma_4_and_mmproj( + slots_per_agent: i32, +) -> Result { + let device = current_test_device()?; + + device.require_available()?; + + let ModelCard { + gpu_layer_count, + reference: primary_reference, + } = gemma_4_e4b_it(); + let ModelCard { + reference: mmproj_reference, + .. + } = gemma_4_e4b_it_mmproj(); + + start_in_process_cluster(InProcessClusterParams { + slots_per_agent, + desired_state: BalancerDesiredState { + chat_template_override: None, + inference_parameters: device.inference_parameters_for_full_offload(gpu_layer_count), + model: AgentDesiredModel::HuggingFace(primary_reference), + multimodal_projection: AgentDesiredModel::HuggingFace(mmproj_reference), + use_chat_template_override: false, + }, + wait_for_slots_ready: true, + ..InProcessClusterParams::default() + }) + .await +} diff --git a/paddler_tests/src/start_in_process_cluster_with_ministral_3_and_mmproj.rs b/paddler_tests/src/start_in_process_cluster_with_ministral_3_and_mmproj.rs new file mode 100644 index 00000000..bdfd108a --- /dev/null +++ b/paddler_tests/src/start_in_process_cluster_with_ministral_3_and_mmproj.rs @@ -0,0 +1,42 @@ +use anyhow::Result; +use paddler_types::agent_desired_model::AgentDesiredModel; +use paddler_types::balancer_desired_state::BalancerDesiredState; + +use crate::cluster_handle::ClusterHandle; +use crate::current_test_device::current_test_device; +use crate::in_process_cluster_params::InProcessClusterParams; +use crate::model_card::ModelCard; +use crate::model_card::ministral_3_14b_reasoning::ministral_3_14b_reasoning; +use crate::model_card::ministral_3_14b_reasoning_mmproj::ministral_3_14b_reasoning_mmproj; +use crate::start_in_process_cluster::start_in_process_cluster; + +pub async fn start_in_process_cluster_with_ministral_3_and_mmproj( + slots_per_agent: i32, +) -> Result { + let device = current_test_device()?; + + device.require_available()?; + + let ModelCard { + gpu_layer_count, + reference: primary_reference, + } = ministral_3_14b_reasoning(); + let ModelCard { + reference: mmproj_reference, + .. + } = ministral_3_14b_reasoning_mmproj(); + + start_in_process_cluster(InProcessClusterParams { + slots_per_agent, + desired_state: BalancerDesiredState { + chat_template_override: None, + inference_parameters: device.inference_parameters_for_full_offload(gpu_layer_count), + model: AgentDesiredModel::HuggingFace(primary_reference), + multimodal_projection: AgentDesiredModel::HuggingFace(mmproj_reference), + use_chat_template_override: false, + }, + wait_for_slots_ready: true, + ..InProcessClusterParams::default() + }) + .await +} diff --git a/paddler_tests/src/start_in_process_cluster_with_qwen3_6_and_mmproj.rs b/paddler_tests/src/start_in_process_cluster_with_qwen3_6_and_mmproj.rs new file mode 100644 index 00000000..add0a7d1 --- /dev/null +++ b/paddler_tests/src/start_in_process_cluster_with_qwen3_6_and_mmproj.rs @@ -0,0 +1,42 @@ +use anyhow::Result; +use paddler_types::agent_desired_model::AgentDesiredModel; +use paddler_types::balancer_desired_state::BalancerDesiredState; + +use crate::cluster_handle::ClusterHandle; +use crate::current_test_device::current_test_device; +use crate::in_process_cluster_params::InProcessClusterParams; +use crate::model_card::ModelCard; +use crate::model_card::qwen3_6_35b_a3b::qwen3_6_35b_a3b; +use crate::model_card::qwen3_6_35b_a3b_mmproj::qwen3_6_35b_a3b_mmproj; +use crate::start_in_process_cluster::start_in_process_cluster; + +pub async fn start_in_process_cluster_with_qwen3_6_and_mmproj( + slots_per_agent: i32, +) -> Result { + let device = current_test_device()?; + + device.require_available()?; + + let ModelCard { + gpu_layer_count, + reference: primary_reference, + } = qwen3_6_35b_a3b(); + let ModelCard { + reference: mmproj_reference, + .. + } = qwen3_6_35b_a3b_mmproj(); + + start_in_process_cluster(InProcessClusterParams { + slots_per_agent, + desired_state: BalancerDesiredState { + chat_template_override: None, + inference_parameters: device.inference_parameters_for_full_offload(gpu_layer_count), + model: AgentDesiredModel::HuggingFace(primary_reference), + multimodal_projection: AgentDesiredModel::HuggingFace(mmproj_reference), + use_chat_template_override: false, + }, + wait_for_slots_ready: true, + ..InProcessClusterParams::default() + }) + .await +} diff --git a/paddler_tests/tests/gemma4_internal_endpoint_emits_reasoning_tokens_for_image_request.rs b/paddler_tests/tests/gemma4_internal_endpoint_emits_reasoning_tokens_for_image_request.rs new file mode 100644 index 00000000..12caeb60 --- /dev/null +++ b/paddler_tests/tests/gemma4_internal_endpoint_emits_reasoning_tokens_for_image_request.rs @@ -0,0 +1,80 @@ +#![cfg(feature = "tests_that_use_llms")] + +use anyhow::Result; +use paddler_tests::collect_generated_tokens::collect_generated_tokens; +use paddler_tests::inference_http_client::InferenceHttpClient; +use paddler_tests::load_test_image_data_uri::load_test_image_data_uri; +use paddler_tests::start_in_process_cluster_with_gemma_4_and_mmproj::start_in_process_cluster_with_gemma_4_and_mmproj; +use paddler_types::conversation_history::ConversationHistory; +use paddler_types::conversation_message::ConversationMessage; +use paddler_types::conversation_message_content::ConversationMessageContent; +use paddler_types::conversation_message_content_part::ConversationMessageContentPart; +use paddler_types::generated_token_result::GeneratedTokenResult; +use paddler_types::image_url::ImageUrl; +use paddler_types::request_params::continue_from_conversation_history_params::ContinueFromConversationHistoryParams; +use reqwest::Client; + +#[serial_test::file_serial(model_load, path => "../target/model_load.lock")] +#[tokio::test(flavor = "multi_thread")] +async fn gemma4_internal_endpoint_emits_reasoning_tokens_for_image_request() -> Result<()> { + let cluster = start_in_process_cluster_with_gemma_4_and_mmproj(1).await?; + + let inference_client = + InferenceHttpClient::new(Client::new(), cluster.addresses.inference_base_url()?); + + let image_data_uri = load_test_image_data_uri()?; + + let conversation_history = ConversationHistory::new(vec![ConversationMessage { + content: ConversationMessageContent::Parts(vec![ + ConversationMessageContentPart::ImageUrl { + image_url: ImageUrl { + url: image_data_uri, + }, + }, + ConversationMessageContentPart::Text { + text: "What animals do you see in this image? Think step by step.".to_owned(), + }, + ]), + role: "user".to_owned(), + }]); + + let stream = inference_client + .post_continue_from_conversation_history(&ContinueFromConversationHistoryParams { + add_generation_prompt: true, + conversation_history, + enable_thinking: true, + grammar: None, + max_tokens: 200, + parse_tool_calls: false, + tools: vec![], + }) + .await?; + + let collected = collect_generated_tokens(stream).await?; + + let reasoning_count = collected + .token_results + .iter() + .filter(|result| matches!(result, GeneratedTokenResult::ReasoningToken(_))) + .count(); + + assert!( + reasoning_count > 0, + "Gemma 4: expected at least one reasoning token from a `<|channel>thought` block when an image is attached (got {reasoning_count})" + ); + + let last = collected + .token_results + .last() + .ok_or_else(|| anyhow::anyhow!("no token results received"))?; + let GeneratedTokenResult::Done(summary) = last else { + anyhow::bail!("last result was not Done: {last:?}"); + }; + + assert!(summary.usage.reasoning_tokens > 0); + assert!(summary.usage.input_image_tokens > 0); + + cluster.shutdown().await?; + + Ok(()) +} diff --git a/paddler_tests/tests/mistral3_internal_endpoint_emits_reasoning_tokens_for_image_request.rs b/paddler_tests/tests/mistral3_internal_endpoint_emits_reasoning_tokens_for_image_request.rs new file mode 100644 index 00000000..385bade9 --- /dev/null +++ b/paddler_tests/tests/mistral3_internal_endpoint_emits_reasoning_tokens_for_image_request.rs @@ -0,0 +1,80 @@ +#![cfg(feature = "tests_that_use_llms")] + +use anyhow::Result; +use paddler_tests::collect_generated_tokens::collect_generated_tokens; +use paddler_tests::inference_http_client::InferenceHttpClient; +use paddler_tests::load_test_image_data_uri::load_test_image_data_uri; +use paddler_tests::start_in_process_cluster_with_ministral_3_and_mmproj::start_in_process_cluster_with_ministral_3_and_mmproj; +use paddler_types::conversation_history::ConversationHistory; +use paddler_types::conversation_message::ConversationMessage; +use paddler_types::conversation_message_content::ConversationMessageContent; +use paddler_types::conversation_message_content_part::ConversationMessageContentPart; +use paddler_types::generated_token_result::GeneratedTokenResult; +use paddler_types::image_url::ImageUrl; +use paddler_types::request_params::continue_from_conversation_history_params::ContinueFromConversationHistoryParams; +use reqwest::Client; + +#[serial_test::file_serial(model_load, path => "../target/model_load.lock")] +#[tokio::test(flavor = "multi_thread")] +async fn mistral3_internal_endpoint_emits_reasoning_tokens_for_image_request() -> Result<()> { + let cluster = start_in_process_cluster_with_ministral_3_and_mmproj(1).await?; + + let inference_client = + InferenceHttpClient::new(Client::new(), cluster.addresses.inference_base_url()?); + + let image_data_uri = load_test_image_data_uri()?; + + let conversation_history = ConversationHistory::new(vec![ConversationMessage { + content: ConversationMessageContent::Parts(vec![ + ConversationMessageContentPart::ImageUrl { + image_url: ImageUrl { + url: image_data_uri, + }, + }, + ConversationMessageContentPart::Text { + text: "What animals do you see in this image? Think step by step.".to_owned(), + }, + ]), + role: "user".to_owned(), + }]); + + let stream = inference_client + .post_continue_from_conversation_history(&ContinueFromConversationHistoryParams { + add_generation_prompt: true, + conversation_history, + enable_thinking: true, + grammar: None, + max_tokens: 400, + parse_tool_calls: false, + tools: vec![], + }) + .await?; + + let collected = collect_generated_tokens(stream).await?; + + let reasoning_count = collected + .token_results + .iter() + .filter(|result| matches!(result, GeneratedTokenResult::ReasoningToken(_))) + .count(); + + assert!( + reasoning_count > 0, + "Mistral 3: expected at least one reasoning token from a `[THINK]` block when an image is attached (got {reasoning_count})" + ); + + let last = collected + .token_results + .last() + .ok_or_else(|| anyhow::anyhow!("no token results received"))?; + let GeneratedTokenResult::Done(summary) = last else { + anyhow::bail!("last result was not Done: {last:?}"); + }; + + assert!(summary.usage.reasoning_tokens > 0); + assert!(summary.usage.input_image_tokens > 0); + + cluster.shutdown().await?; + + Ok(()) +} diff --git a/paddler_tests/tests/qwen35_internal_endpoint_emits_reasoning_tokens_for_image_request.rs b/paddler_tests/tests/qwen35_internal_endpoint_emits_reasoning_tokens_for_image_request.rs new file mode 100644 index 00000000..10755c6e --- /dev/null +++ b/paddler_tests/tests/qwen35_internal_endpoint_emits_reasoning_tokens_for_image_request.rs @@ -0,0 +1,80 @@ +#![cfg(feature = "tests_that_use_llms")] + +use anyhow::Result; +use paddler_tests::collect_generated_tokens::collect_generated_tokens; +use paddler_tests::inference_http_client::InferenceHttpClient; +use paddler_tests::load_test_image_data_uri::load_test_image_data_uri; +use paddler_tests::start_in_process_cluster_with_qwen3_5::start_in_process_cluster_with_qwen3_5; +use paddler_types::conversation_history::ConversationHistory; +use paddler_types::conversation_message::ConversationMessage; +use paddler_types::conversation_message_content::ConversationMessageContent; +use paddler_types::conversation_message_content_part::ConversationMessageContentPart; +use paddler_types::generated_token_result::GeneratedTokenResult; +use paddler_types::image_url::ImageUrl; +use paddler_types::request_params::continue_from_conversation_history_params::ContinueFromConversationHistoryParams; +use reqwest::Client; + +#[serial_test::file_serial(model_load, path => "../target/model_load.lock")] +#[tokio::test(flavor = "multi_thread")] +async fn qwen35_internal_endpoint_emits_reasoning_tokens_for_image_request() -> Result<()> { + let cluster = start_in_process_cluster_with_qwen3_5(1, true).await?; + + let inference_client = + InferenceHttpClient::new(Client::new(), cluster.addresses.inference_base_url()?); + + let image_data_uri = load_test_image_data_uri()?; + + let conversation_history = ConversationHistory::new(vec![ConversationMessage { + content: ConversationMessageContent::Parts(vec![ + ConversationMessageContentPart::ImageUrl { + image_url: ImageUrl { + url: image_data_uri, + }, + }, + ConversationMessageContentPart::Text { + text: "What animals do you see in this image? Think step by step.".to_owned(), + }, + ]), + role: "user".to_owned(), + }]); + + let stream = inference_client + .post_continue_from_conversation_history(&ContinueFromConversationHistoryParams { + add_generation_prompt: true, + conversation_history, + enable_thinking: true, + grammar: None, + max_tokens: 200, + parse_tool_calls: false, + tools: vec![], + }) + .await?; + + let collected = collect_generated_tokens(stream).await?; + + let reasoning_count = collected + .token_results + .iter() + .filter(|result| matches!(result, GeneratedTokenResult::ReasoningToken(_))) + .count(); + + assert!( + reasoning_count > 0, + "Qwen 3.5: expected at least one reasoning token from a `` block when an image is attached (got {reasoning_count})" + ); + + let last = collected + .token_results + .last() + .ok_or_else(|| anyhow::anyhow!("no token results received"))?; + let GeneratedTokenResult::Done(summary) = last else { + anyhow::bail!("last result was not Done: {last:?}"); + }; + + assert!(summary.usage.reasoning_tokens > 0); + assert!(summary.usage.input_image_tokens > 0); + + cluster.shutdown().await?; + + Ok(()) +} diff --git a/paddler_tests/tests/qwen36_internal_endpoint_emits_reasoning_tokens_for_image_request.rs b/paddler_tests/tests/qwen36_internal_endpoint_emits_reasoning_tokens_for_image_request.rs new file mode 100644 index 00000000..449cd8b9 --- /dev/null +++ b/paddler_tests/tests/qwen36_internal_endpoint_emits_reasoning_tokens_for_image_request.rs @@ -0,0 +1,80 @@ +#![cfg(feature = "tests_that_use_llms")] + +use anyhow::Result; +use paddler_tests::collect_generated_tokens::collect_generated_tokens; +use paddler_tests::inference_http_client::InferenceHttpClient; +use paddler_tests::load_test_image_data_uri::load_test_image_data_uri; +use paddler_tests::start_in_process_cluster_with_qwen3_6_and_mmproj::start_in_process_cluster_with_qwen3_6_and_mmproj; +use paddler_types::conversation_history::ConversationHistory; +use paddler_types::conversation_message::ConversationMessage; +use paddler_types::conversation_message_content::ConversationMessageContent; +use paddler_types::conversation_message_content_part::ConversationMessageContentPart; +use paddler_types::generated_token_result::GeneratedTokenResult; +use paddler_types::image_url::ImageUrl; +use paddler_types::request_params::continue_from_conversation_history_params::ContinueFromConversationHistoryParams; +use reqwest::Client; + +#[serial_test::file_serial(model_load, path => "../target/model_load.lock")] +#[tokio::test(flavor = "multi_thread")] +async fn qwen36_internal_endpoint_emits_reasoning_tokens_for_image_request() -> Result<()> { + let cluster = start_in_process_cluster_with_qwen3_6_and_mmproj(1).await?; + + let inference_client = + InferenceHttpClient::new(Client::new(), cluster.addresses.inference_base_url()?); + + let image_data_uri = load_test_image_data_uri()?; + + let conversation_history = ConversationHistory::new(vec![ConversationMessage { + content: ConversationMessageContent::Parts(vec![ + ConversationMessageContentPart::ImageUrl { + image_url: ImageUrl { + url: image_data_uri, + }, + }, + ConversationMessageContentPart::Text { + text: "What animals do you see in this image? Think step by step.".to_owned(), + }, + ]), + role: "user".to_owned(), + }]); + + let stream = inference_client + .post_continue_from_conversation_history(&ContinueFromConversationHistoryParams { + add_generation_prompt: true, + conversation_history, + enable_thinking: true, + grammar: None, + max_tokens: 200, + parse_tool_calls: false, + tools: vec![], + }) + .await?; + + let collected = collect_generated_tokens(stream).await?; + + let reasoning_count = collected + .token_results + .iter() + .filter(|result| matches!(result, GeneratedTokenResult::ReasoningToken(_))) + .count(); + + assert!( + reasoning_count > 0, + "Qwen 3.6: expected at least one reasoning token from a `` block when an image is attached (got {reasoning_count})" + ); + + let last = collected + .token_results + .last() + .ok_or_else(|| anyhow::anyhow!("no token results received"))?; + let GeneratedTokenResult::Done(summary) = last else { + anyhow::bail!("last result was not Done: {last:?}"); + }; + + assert!(summary.usage.reasoning_tokens > 0); + assert!(summary.usage.input_image_tokens > 0); + + cluster.shutdown().await?; + + Ok(()) +} From 2a704c1f33c8ba3f02fa293de30239466e925793 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ma=C5=82gorzata=20Zagajewska?= Date: Fri, 8 May 2026 23:38:45 +0200 Subject: [PATCH 25/51] add paddler client CLI application --- Cargo.lock | 785 ++++++++++++++++-- Cargo.toml | 4 +- paddler_client_cli/Cargo.toml | 33 + paddler_client_cli/examples/calculator.json | 27 + paddler_client_cli/examples/get_weather.json | 23 + .../examples/negotiate_with_cat.json | 29 + paddler_client_cli/src/chat_panel_layout.rs | 61 ++ paddler_client_cli/src/chat_session.rs | 172 ++++ paddler_client_cli/src/chat_session_event.rs | 14 + paddler_client_cli/src/cmd/handler.rs | 8 + paddler_client_cli/src/cmd/load_tool.rs | 17 + paddler_client_cli/src/cmd/mod.rs | 5 + paddler_client_cli/src/cmd/prompt.rs | 73 ++ paddler_client_cli/src/cmd/thinking_mode.rs | 14 + .../src/cmd/value_parser/mod.rs | 1 + .../cmd/value_parser/parse_inference_url.rs | 6 + paddler_client_cli/src/main.rs | 50 ++ paddler_client_cli/src/panel_kind.rs | 19 + paddler_client_cli/src/panel_navigation.rs | 180 ++++ paddler_client_cli/src/raw_terminal_guard.rs | 57 ++ paddler_client_cli/src/render_chat_panels.rs | 262 ++++++ paddler_client_cli/src/stop_reason.rs | 74 ++ paddler_client_cli/src/streaming_response.rs | 214 +++++ 23 files changed, 2061 insertions(+), 67 deletions(-) create mode 100644 paddler_client_cli/Cargo.toml create mode 100644 paddler_client_cli/examples/calculator.json create mode 100644 paddler_client_cli/examples/get_weather.json create mode 100644 paddler_client_cli/examples/negotiate_with_cat.json create mode 100644 paddler_client_cli/src/chat_panel_layout.rs create mode 100644 paddler_client_cli/src/chat_session.rs create mode 100644 paddler_client_cli/src/chat_session_event.rs create mode 100644 paddler_client_cli/src/cmd/handler.rs create mode 100644 paddler_client_cli/src/cmd/load_tool.rs create mode 100644 paddler_client_cli/src/cmd/mod.rs create mode 100644 paddler_client_cli/src/cmd/prompt.rs create mode 100644 paddler_client_cli/src/cmd/thinking_mode.rs create mode 100644 paddler_client_cli/src/cmd/value_parser/mod.rs create mode 100644 paddler_client_cli/src/cmd/value_parser/parse_inference_url.rs create mode 100644 paddler_client_cli/src/main.rs create mode 100644 paddler_client_cli/src/panel_kind.rs create mode 100644 paddler_client_cli/src/panel_navigation.rs create mode 100644 paddler_client_cli/src/raw_terminal_guard.rs create mode 100644 paddler_client_cli/src/render_chat_panels.rs create mode 100644 paddler_client_cli/src/stop_reason.rs create mode 100644 paddler_client_cli/src/streaming_response.rs diff --git a/Cargo.lock b/Cargo.lock index 4e0fb331..dab7e765 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -121,7 +121,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e01ed3140b2f8d422c68afa1ed2e85d996ea619c988ac834d255db32138655cb" dependencies = [ "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -239,7 +239,7 @@ dependencies = [ "actix-router", "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -287,7 +287,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2ac4d6e04e97fe707286509b4f338e99c5fb7249c770e1da074af5e27faa96b3" dependencies = [ "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -312,7 +312,7 @@ checksum = "b6ac1e58cded18cb28ddc17143c4dea5345b3ad575e14f32f66e4054a56eb271" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -505,7 +505,7 @@ checksum = "0ae92a5119aa49cdbcf6b9f893fe4e1d98b04ccbf82ee0584ad948a44a734dea" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -571,7 +571,7 @@ dependencies = [ "rustc-hash 2.1.2", "serde", "serde_derive", - "syn", + "syn 2.0.117", ] [[package]] @@ -679,7 +679,7 @@ checksum = "3b43422f69d8ff38f95f1b2bb76517c91589a924d1559a0e935d7c8ce0274c11" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -719,7 +719,7 @@ checksum = "c7c24de15d275a1ecfd47a380fb4d5ec9bfe0933f309ed5e705b775596a3574d" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -736,7 +736,16 @@ checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", +] + +[[package]] +name = "atomic" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89cbf775b137e9b968e67227ef7f775587cde3fd31b0d8599dbd0f598a48340" +dependencies = [ + "bytemuck", ] [[package]] @@ -826,7 +835,16 @@ dependencies = [ "regex", "rustc-hash 2.1.2", "shlex", - "syn", + "syn 2.0.117", +] + +[[package]] +name = "bit-set" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0700ddab506f33b20a03b13996eccd309a48e5ff77d0d95926aa0210fb4e95f1" +dependencies = [ + "bit-vec 0.6.3", ] [[package]] @@ -835,9 +853,15 @@ version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3" dependencies = [ - "bit-vec", + "bit-vec 0.8.0", ] +[[package]] +name = "bit-vec" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "349f9b6a179ed607305526ca489b34ad0a41aed5f7980fa90eb03160b69598fb" + [[package]] name = "bit-vec" version = "0.8.0" @@ -988,7 +1012,7 @@ checksum = "f9abbd1bc6865053c427f7198e6af43bfdedc55ab791faed4fbd361d789575ff" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -1088,6 +1112,15 @@ dependencies = [ "wayland-client", ] +[[package]] +name = "castaway" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dec551ab6e7578819132c713a93c022a05d60159dc86e7a7050223577484c55a" +dependencies = [ + "rustversion", +] + [[package]] name = "cc" version = "1.2.59" @@ -1180,7 +1213,7 @@ dependencies = [ "heck", "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -1270,6 +1303,20 @@ dependencies = [ "memchr", ] +[[package]] +name = "compact_str" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3fdb1325a1cece981e8a296ab8f0f9b63ae357bd0784a9faaf548cc7b480707a" +dependencies = [ + "castaway", + "cfg-if", + "itoa", + "rustversion", + "ryu", + "static_assertions", +] + [[package]] name = "concurrent-queue" version = "2.5.0" @@ -1482,6 +1529,34 @@ version = "0.8.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" +[[package]] +name = "crossterm" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8b9f2e4c67f833b660cdb0a3523065869fb35570177239812ed4c905aeff87b" +dependencies = [ + "bitflags 2.11.0", + "crossterm_winapi", + "derive_more", + "document-features", + "futures-core", + "mio", + "parking_lot", + "rustix 1.1.4", + "signal-hook", + "signal-hook-mio", + "winapi", +] + +[[package]] +name = "crossterm_winapi" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "acdd7c62a3665c7f6830a51635d9ac9b23ed385797f70a83bb8bafe9c572ab2b" +dependencies = [ + "winapi", +] + [[package]] name = "crunchy" version = "0.2.4" @@ -1520,6 +1595,16 @@ dependencies = [ "hybrid-array", ] +[[package]] +name = "csscolorparser" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eb2a7d3066da2de787b7f032c736763eb7ae5d355f81a68bab2675a96008b0bf" +dependencies = [ + "lab", + "phf", +] + [[package]] name = "csv" version = "1.4.0" @@ -1553,6 +1638,40 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f27ae1dd37df86211c42e150270f82743308803d90a6f6e6651cd730d5e1732f" +[[package]] +name = "darling" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25ae13da2f202d56bd7f91c25fba009e7717a1e4a1cc98a76d844b65ae912e9d" +dependencies = [ + "darling_core", + "darling_macro", +] + +[[package]] +name = "darling_core" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9865a50f7c335f53564bb694ef660825eb8610e0a53d3e11bf1b0d3df31e03b0" +dependencies = [ + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn 2.0.117", +] + +[[package]] +name = "darling_macro" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d" +dependencies = [ + "darling_core", + "quote", + "syn 2.0.117", +] + [[package]] name = "dashmap" version = "6.1.0" @@ -1579,6 +1698,12 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "be1e0bca6c3637f992fc1cc7cbc52a78c1ef6db076dbf1059c4323d6a2048376" +[[package]] +name = "deltae" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5729f5117e208430e437df2f4843f5e5952997175992d1414f94c57d61e270b4" + [[package]] name = "deranged" version = "0.5.8" @@ -1608,7 +1733,7 @@ dependencies = [ "proc-macro2", "quote", "rustc_version", - "syn", + "syn 2.0.117", "unicode-xid", ] @@ -1699,7 +1824,7 @@ checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -1787,7 +1912,7 @@ checksum = "67c78a4d8fdf9953a5c9d458f9efe940fd97a0cab0941c075a813ac594733827" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -1830,7 +1955,7 @@ checksum = "44f23cf4b44bfce11a86ace86f8a73ffdec849c9fd00a386a53d278bd9e81fb3" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -1927,13 +2052,23 @@ dependencies = [ "zune-inflate", ] +[[package]] +name = "fancy-regex" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b95f7c0680e4142284cf8b22c14a476e87d61b004a3a0861872b32ef7ead40a2" +dependencies = [ + "bit-set 0.5.3", + "regex", +] + [[package]] name = "fancy-regex" version = "0.16.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "998b056554fbe42e03ae0e152895cd1a7e1002aec800fdc6635d20270260c46f" dependencies = [ - "bit-set", + "bit-set 0.8.0", "regex-automata", "regex-syntax", ] @@ -1961,7 +2096,7 @@ checksum = "a0aca10fb742cb43f9e7bb8467c91aa9bcb8e3ffbc6a6f7389bb93ffc920577d" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -1973,6 +2108,17 @@ dependencies = [ "simd-adler32", ] +[[package]] +name = "filedescriptor" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e40758ed24c9b2eeb76c35fb0aebc66c626084edd827e07e1552279814c6682d" +dependencies = [ + "libc", + "thiserror 1.0.69", + "winapi", +] + [[package]] name = "find-msvc-tools" version = "0.1.9" @@ -1988,6 +2134,18 @@ dependencies = [ "glob", ] +[[package]] +name = "finl_unicode" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9844ddc3a6e533d62bba727eb6c28b5d360921d5175e9ff0f1e621a5c590a4d5" + +[[package]] +name = "fixedbitset" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ce7134b9999ecaf8bcd65542e436736ef32ddca1b3e06094cb6ec5755203b80" + [[package]] name = "flate2" version = "1.1.9" @@ -2112,7 +2270,7 @@ checksum = "1a5c6c585bc94aaf2c7b51dd4c2ba22680844aba4c687be581871a6f518c5742" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -2231,7 +2389,7 @@ checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -3006,6 +3164,12 @@ version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" +[[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + [[package]] name = "idna" version = "1.1.0" @@ -3135,6 +3299,19 @@ dependencies = [ "rustversion", ] +[[package]] +name = "instability" +version = "0.3.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5eb2d60ef19920a3a9193c3e371f726ec1dafc045dac788d0fb3704272458971" +dependencies = [ + "darling", + "indoc", + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "interpolate_name" version = "0.2.4" @@ -3143,7 +3320,7 @@ checksum = "c34819042dc3d3971c46c2190835914dfbe0c3c13f61449b2997f4e9722dfa60" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -3241,7 +3418,7 @@ checksum = "2a8c8b344124222efd714b73bb41f8b5120b27a7cc1c75593a6ff768d9d05aa4" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -3287,7 +3464,7 @@ dependencies = [ "quote", "rustc_version", "simd_cesu8", - "syn", + "syn 2.0.117", ] [[package]] @@ -3315,7 +3492,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264" dependencies = [ "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -3350,7 +3527,7 @@ dependencies = [ "bytecount", "data-encoding", "email_address", - "fancy-regex", + "fancy-regex 0.16.2", "fraction", "getrandom 0.3.4", "idna", @@ -3376,6 +3553,17 @@ dependencies = [ "mutate_once", ] +[[package]] +name = "kasuari" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bde5057d6143cc94e861d90f591b9303d6716c6b9602309150bd068853c10899" +dependencies = [ + "hashbrown 0.16.1", + "portable-atomic", + "thiserror 2.0.18", +] + [[package]] name = "khronos-egl" version = "6.0.0" @@ -3425,6 +3613,12 @@ dependencies = [ "smallvec", ] +[[package]] +name = "lab" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf36173d4167ed999940f804952e6b08197cae5ad5d572eb4db150ce8ad5d58f" + [[package]] name = "language-tags" version = "0.3.2" @@ -3502,6 +3696,15 @@ dependencies = [ "web-time", ] +[[package]] +name = "line-clipping" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f50e8f47623268b5407192d26876c4d7f89d686ca130fdc53bced4814cd29f8" +dependencies = [ + "bitflags 2.11.0", +] + [[package]] name = "linebender_resource_handle" version = "0.1.1" @@ -3525,7 +3728,7 @@ checksum = "e5cec0ec4228b4853bb129c84dbf093a27e6c7a20526da046defc334a1b017f7" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -3660,6 +3863,19 @@ name = "lru" version = "0.16.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f66e8d5d03f609abc3a39e6f08e4164ebf1447a732906d39eb9b99b7919ef39" +dependencies = [ + "hashbrown 0.16.1", +] + +[[package]] +name = "mac_address" +version = "1.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0aeb26bf5e836cc1c341c8106051b573f1766dfa05aa87f0b98be5e51b02303" +dependencies = [ + "nix 0.29.0", + "winapi", +] [[package]] name = "macro_registry" @@ -3668,7 +3884,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "28c03fc749d06e1000766283015673e91aea121f30c60b7445681f2248e4994c" dependencies = [ "module_path_extractor", - "syn", + "syn 2.0.117", ] [[package]] @@ -3705,6 +3921,12 @@ dependencies = [ "libc", ] +[[package]] +name = "memmem" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a64a92489e2744ce060c349162be1c5f33c6969234104dbd99ddb5feb08b8c15" + [[package]] name = "memo-map" version = "0.3.3" @@ -3863,7 +4085,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "066cf25f0e8b11ee0df221219010f213ad429855f57c494f995590c861a9a7d8" dependencies = [ "arrayvec", - "bit-set", + "bit-set 0.8.0", "bitflags 2.11.0", "cfg-if", "cfg_aliases", @@ -3944,6 +4166,19 @@ version = "1.0.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086" +[[package]] +name = "nix" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "71e2746dc3a24dd78b3cfcb7be93368c6de9963d30f43a6a73998a9cf4b17b46" +dependencies = [ + "bitflags 2.11.0", + "cfg-if", + "cfg_aliases", + "libc", + "memoffset", +] + [[package]] name = "nix" version = "0.30.1" @@ -4034,7 +4269,7 @@ checksum = "ed3955f1a9c7c0c15e092f9c887db08b1fc683305fdf6eb6684f22555355e202" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -4107,7 +4342,16 @@ dependencies = [ "proc-macro-crate", "proc-macro2", "quote", - "syn", + "syn 2.0.117", +] + +[[package]] +name = "num_threads" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c7398b9c8b70908f6371f47ed36737907c87c52af34c268fed0bf0ceb92ead9" +dependencies = [ + "libc", ] [[package]] @@ -4521,7 +4765,7 @@ checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -4558,6 +4802,15 @@ dependencies = [ "libredox", ] +[[package]] +name = "ordered-float" +version = "4.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7bb71e1b3fa6ca1c61f383464aaf2bb0e2f8e772a1f01d486832464de363b951" +dependencies = [ + "num-traits", +] + [[package]] name = "ordered-float" version = "5.3.0" @@ -4700,6 +4953,29 @@ dependencies = [ "url", ] +[[package]] +name = "paddler_client_cli" +version = "3.1.2" +dependencies = [ + "anyhow", + "async-trait", + "clap", + "crossterm", + "env_logger", + "futures-util", + "llama-cpp-bindings-types", + "log", + "paddler_bootstrap", + "paddler_client", + "paddler_types", + "ratatui", + "reqwest", + "serde_json", + "tokio", + "tokio-util", + "url", +] + [[package]] name = "paddler_gui" version = "3.1.2" @@ -4733,7 +5009,7 @@ dependencies = [ "hf-hub", "llama-cpp-bindings", "log", - "nix", + "nix 0.30.1", "paddler", "paddler_bootstrap", "paddler_client", @@ -4813,6 +5089,101 @@ version = "2.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" +[[package]] +name = "pest" +version = "2.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e0848c601009d37dfa3430c4666e147e49cdcf1b92ecd3e63657d8a5f19da662" +dependencies = [ + "memchr", + "ucd-trie", +] + +[[package]] +name = "pest_derive" +version = "2.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11f486f1ea21e6c10ed15d5a7c77165d0ee443402f0780849d1768e7d9d6fe77" +dependencies = [ + "pest", + "pest_generator", +] + +[[package]] +name = "pest_generator" +version = "2.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8040c4647b13b210a963c1ed407c1ff4fdfa01c31d6d2a098218702e6664f94f" +dependencies = [ + "pest", + "pest_meta", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "pest_meta" +version = "2.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "89815c69d36021a140146f26659a81d6c2afa33d216d736dd4be5381a7362220" +dependencies = [ + "pest", + "sha2", +] + +[[package]] +name = "phf" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd6780a80ae0c52cc120a26a1a42c1ae51b247a253e4e06113d23d2c2edd078" +dependencies = [ + "phf_macros", + "phf_shared", +] + +[[package]] +name = "phf_codegen" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aef8048c789fa5e851558d709946d6d79a8ff88c0440c587967f8e94bfb1216a" +dependencies = [ + "phf_generator", + "phf_shared", +] + +[[package]] +name = "phf_generator" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d" +dependencies = [ + "phf_shared", + "rand 0.8.6", +] + +[[package]] +name = "phf_macros" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f84ac04429c13a7ff43785d75ad27569f2951ce0ffd30a3321230db2fc727216" +dependencies = [ + "phf_generator", + "phf_shared", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "phf_shared" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67eabc2ef2a60eb7faa00097bd1ffdb5bd28e62bf39990626a582201b7a754e5" +dependencies = [ + "siphasher", +] + [[package]] name = "pico-args" version = "0.5.0" @@ -4836,7 +5207,7 @@ checksum = "d9b20ed30f105399776b9c883e68e536ef602a16ae6f596d2c473591d6ad64c6" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -4976,7 +5347,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" dependencies = [ "proc-macro2", - "syn", + "syn 2.0.117", ] [[package]] @@ -5013,7 +5384,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "52717f9a02b6965224f95ca2a81e2e0c5c43baacd28ca057577988930b6c3d5b" dependencies = [ "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -5155,6 +5526,91 @@ version = "1.7.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "973443cf09a9c8656b574a866ab68dfa19f0867d0340648c7d2f6a71b8a8ea68" +[[package]] +name = "ratatui" +version = "0.30.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1ce67fb8ba4446454d1c8dbaeda0557ff5e94d39d5e5ed7f10a65eb4c8266bc" +dependencies = [ + "instability", + "ratatui-core", + "ratatui-crossterm", + "ratatui-macros", + "ratatui-termwiz", + "ratatui-widgets", +] + +[[package]] +name = "ratatui-core" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ef8dea09a92caaf73bff7adb70b76162e5937524058a7e5bff37869cbbec293" +dependencies = [ + "bitflags 2.11.0", + "compact_str", + "hashbrown 0.16.1", + "indoc", + "itertools 0.14.0", + "kasuari", + "lru", + "strum", + "thiserror 2.0.18", + "unicode-segmentation", + "unicode-truncate", + "unicode-width", +] + +[[package]] +name = "ratatui-crossterm" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "577c9b9f652b4c121fb25c6a391dd06406d3b092ba68827e6d2f09550edc54b3" +dependencies = [ + "cfg-if", + "crossterm", + "instability", + "ratatui-core", +] + +[[package]] +name = "ratatui-macros" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7f1342a13e83e4bb9d0b793d0ea762be633f9582048c892ae9041ef39c936f4" +dependencies = [ + "ratatui-core", + "ratatui-widgets", +] + +[[package]] +name = "ratatui-termwiz" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f76fe0bd0ed4295f0321b1676732e2454024c15a35d01904ddb315afd3d545c" +dependencies = [ + "ratatui-core", + "termwiz", +] + +[[package]] +name = "ratatui-widgets" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7dbfa023cd4e604c2553483820c5fe8aa9d71a42eea5aa77c6e7f35756612db" +dependencies = [ + "bitflags 2.11.0", + "hashbrown 0.16.1", + "indoc", + "instability", + "itertools 0.14.0", + "line-clipping", + "ratatui-core", + "strum", + "time", + "unicode-segmentation", + "unicode-width", +] + [[package]] name = "rav1e" version = "0.8.1" @@ -5307,7 +5763,7 @@ checksum = "b7186006dcb21920990093f30e3dea63b7d6e977bf1256be20c3563a5db070da" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -5516,7 +5972,7 @@ dependencies = [ "quote", "rust-embed-utils", "shellexpand", - "syn", + "syn 2.0.117", "walkdir", ] @@ -5762,7 +6218,7 @@ checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -5811,7 +6267,7 @@ checksum = "175ee3e80ae9982737ca543e96133087cbd9a485eecc3bc4de9c1a37b47ea59c" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -5850,7 +6306,7 @@ checksum = "0a7d91949b85b0d2fb687445e448b40d322b6b3e4af6b44a29b21d9a5f33e6d9" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -5901,6 +6357,27 @@ version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" +[[package]] +name = "signal-hook" +version = "0.3.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d881a16cf4426aa584979d30bd82cb33429027e42122b169753d6ef1085ed6e2" +dependencies = [ + "libc", + "signal-hook-registry", +] + +[[package]] +name = "signal-hook-mio" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b75a19a7a740b25bc7944bdee6172368f988763b744e3d4dfe753f6b4ece40cc" +dependencies = [ + "libc", + "mio", + "signal-hook", +] + [[package]] name = "signal-hook-registry" version = "1.4.8" @@ -6189,7 +6666,7 @@ dependencies = [ "moddef", "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -6225,7 +6702,7 @@ dependencies = [ "heck", "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -6271,6 +6748,17 @@ dependencies = [ "zeno", ] +[[package]] +name = "syn" +version = "1.0.109" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + [[package]] name = "syn" version = "2.0.117" @@ -6299,7 +6787,7 @@ checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -6354,6 +6842,69 @@ dependencies = [ "winapi-util", ] +[[package]] +name = "terminfo" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4ea810f0692f9f51b382fff5893887bb4580f5fa246fde546e0b13e7fcee662" +dependencies = [ + "fnv", + "nom 7.1.3", + "phf", + "phf_codegen", +] + +[[package]] +name = "termios" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "411c5bf740737c7918b8b1fe232dca4dc9f8e754b8ad5e20966814001ed0ac6b" +dependencies = [ + "libc", +] + +[[package]] +name = "termwiz" +version = "0.23.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4676b37242ccbd1aabf56edb093a4827dc49086c0ffd764a5705899e0f35f8f7" +dependencies = [ + "anyhow", + "base64", + "bitflags 2.11.0", + "fancy-regex 0.11.0", + "filedescriptor", + "finl_unicode", + "fixedbitset", + "hex", + "lazy_static", + "libc", + "log", + "memmem", + "nix 0.29.0", + "num-derive", + "num-traits", + "ordered-float 4.6.0", + "pest", + "pest_derive", + "phf", + "sha2", + "signal-hook", + "siphasher", + "terminfo", + "termios", + "thiserror 1.0.69", + "ucd-trie", + "unicode-segmentation", + "vtparse", + "wezterm-bidi", + "wezterm-blob-leases", + "wezterm-color-types", + "wezterm-dynamic", + "wezterm-input-types", + "winapi", +] + [[package]] name = "textwrap" version = "0.16.2" @@ -6389,7 +6940,7 @@ checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -6400,7 +6951,7 @@ checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -6425,7 +6976,9 @@ checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c" dependencies = [ "deranged", "itoa", + "libc", "num-conv", + "num_threads", "powerfmt", "serde_core", "time-core", @@ -6548,7 +7101,7 @@ checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -6727,7 +7280,7 @@ checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -6778,6 +7331,12 @@ version = "1.19.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb" +[[package]] +name = "ucd-trie" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2896d95c02a80c6d6a5d6e953d479f5ddf2dfdb6a244441010e373ac0fb88971" + [[package]] name = "uds_windows" version = "1.2.1" @@ -6849,6 +7408,17 @@ version = "1.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9629274872b2bfaf8d66f5f15725007f635594914870f65218920345aa11aa8c" +[[package]] +name = "unicode-truncate" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16b380a1238663e5f8a691f9039c73e1cdae598a30e9855f541d29b08b53e9a5" +dependencies = [ + "itertools 0.14.0", + "unicode-segmentation", + "unicode-width", +] + [[package]] name = "unicode-vo" version = "0.1.0" @@ -6990,6 +7560,8 @@ version = "1.23.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ddd74a9687298c6858e9b88ec8935ec45d22e8fd5e6394fa1bd4e99a87789c76" dependencies = [ + "atomic", + "getrandom 0.4.2", "js-sys", "serde_core", "wasm-bindgen", @@ -7040,6 +7612,15 @@ version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5c3082ca00d5a5ef149bb8b555a72ae84c9c59f7250f013ac822ac2e49b19c64" +[[package]] +name = "vtparse" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d9b2acfb050df409c972a37d3b8e08cdea3bddb0c09db9d53137e504cfabed0" +dependencies = [ + "utf8parse", +] + [[package]] name = "walkdir" version = "2.5.0" @@ -7125,7 +7706,7 @@ dependencies = [ "bumpalo", "proc-macro2", "quote", - "syn", + "syn 2.0.117", "wasm-bindgen-shared", ] @@ -7378,6 +7959,78 @@ version = "0.1.12" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a28ac98ddc8b9274cb41bb4d9d4d5c425b6020c50c46f25559911905610b4a88" +[[package]] +name = "wezterm-bidi" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c0a6e355560527dd2d1cf7890652f4f09bb3433b6aadade4c9b5ed76de5f3ec" +dependencies = [ + "log", + "wezterm-dynamic", +] + +[[package]] +name = "wezterm-blob-leases" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "692daff6d93d94e29e4114544ef6d5c942a7ed998b37abdc19b17136ea428eb7" +dependencies = [ + "getrandom 0.3.4", + "mac_address", + "sha2", + "thiserror 1.0.69", + "uuid", +] + +[[package]] +name = "wezterm-color-types" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7de81ef35c9010270d63772bebef2f2d6d1f2d20a983d27505ac850b8c4b4296" +dependencies = [ + "csscolorparser", + "deltae", + "lazy_static", + "wezterm-dynamic", +] + +[[package]] +name = "wezterm-dynamic" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f2ab60e120fd6eaa68d9567f3226e876684639d22a4219b313ff69ec0ccd5ac" +dependencies = [ + "log", + "ordered-float 4.6.0", + "strsim", + "thiserror 1.0.69", + "wezterm-dynamic-derive", +] + +[[package]] +name = "wezterm-dynamic-derive" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46c0cf2d539c645b448eaffec9ec494b8b19bd5077d9e58cb1ae7efece8d575b" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "wezterm-input-types" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7012add459f951456ec9d6c7e6fc340b1ce15d6fc9629f8c42853412c029e57e" +dependencies = [ + "bitflags 1.3.2", + "euclid", + "lazy_static", + "serde", + "wezterm-dynamic", +] + [[package]] name = "wgpu" version = "27.0.1" @@ -7414,8 +8067,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "27a75de515543b1897b26119f93731b385a19aea165a1ec5f0e3acecc229cae7" dependencies = [ "arrayvec", - "bit-set", - "bit-vec", + "bit-set 0.8.0", + "bit-vec 0.8.0", "bitflags 2.11.0", "bytemuck", "cfg_aliases", @@ -7475,7 +8128,7 @@ dependencies = [ "android_system_properties", "arrayvec", "ash", - "bit-set", + "bit-set 0.8.0", "bitflags 2.11.0", "block", "bytemuck", @@ -7498,7 +8151,7 @@ dependencies = [ "ndk-sys", "objc", "once_cell", - "ordered-float", + "ordered-float 5.3.0", "parking_lot", "portable-atomic", "portable-atomic-util", @@ -7650,7 +8303,7 @@ checksum = "2bbd5b46c938e506ecbce286b6628a02171d56153ba733b6c741fc627ec9579b" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -7661,7 +8314,7 @@ checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -7672,7 +8325,7 @@ checksum = "053c4c462dc91d3b1504c6fe5a726dd15e216ba718e84a0e46a88fbe5ded3515" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -7683,7 +8336,7 @@ checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -8090,7 +8743,7 @@ dependencies = [ "heck", "indexmap", "prettyplease", - "syn", + "syn 2.0.117", "wasm-metadata", "wit-bindgen-core", "wit-component", @@ -8106,7 +8759,7 @@ dependencies = [ "prettyplease", "proc-macro2", "quote", - "syn", + "syn 2.0.117", "wit-bindgen-core", "wit-bindgen-rust", ] @@ -8260,7 +8913,7 @@ checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", "synstructure", ] @@ -8308,7 +8961,7 @@ dependencies = [ "proc-macro-crate", "proc-macro2", "quote", - "syn", + "syn 2.0.117", "zbus_names", "zvariant", "zvariant_utils", @@ -8348,7 +9001,7 @@ checksum = "70e3cd084b1788766f53af483dd21f93881ff30d7320490ec3ef7526d203bad4" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -8368,7 +9021,7 @@ checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", "synstructure", ] @@ -8408,7 +9061,7 @@ checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -8507,7 +9160,7 @@ dependencies = [ "proc-macro-crate", "proc-macro2", "quote", - "syn", + "syn 2.0.117", "zvariant_utils", ] @@ -8520,6 +9173,6 @@ dependencies = [ "proc-macro2", "quote", "serde", - "syn", + "syn 2.0.117", "winnow 0.7.15", ] diff --git a/Cargo.toml b/Cargo.toml index 5c5f0169..13ff1a0a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,5 +1,5 @@ [workspace] -members = ["paddler", "paddler_bootstrap", "paddler_cli", "paddler_client", "paddler_gui", "paddler_tests", "paddler_types"] +members = ["paddler", "paddler_bootstrap", "paddler_cli", "paddler_client", "paddler_client_cli", "paddler_gui", "paddler_tests", "paddler_types"] resolver = "2" [workspace.package] @@ -25,6 +25,7 @@ async-trait = "0.1" bytes = "1.11" cadence = "1.6" clap = { version = "4.5", features = ["derive"] } +crossterm = { version = "=0.29.0", features = ["event-stream"] } dashmap = "6.1" encoding_rs = { version = "0.8", features = ["serde"] } env_logger = "0.11" @@ -48,6 +49,7 @@ nix = { version = "0.30", features = ["signal"] } open = "5.3.4" pastey = "0.2" rand = "0.9" +ratatui = "=0.30.0" reqwest = { version = "0.12", features = ["json", "stream"] } resvg = "0.46" rust-embed = { version = "8.9", features = ["interpolate-folder-path"] } diff --git a/paddler_client_cli/Cargo.toml b/paddler_client_cli/Cargo.toml new file mode 100644 index 00000000..9b56ade1 --- /dev/null +++ b/paddler_client_cli/Cargo.toml @@ -0,0 +1,33 @@ +[package] +name = "paddler_client_cli" +version.workspace = true +edition.workspace = true +authors.workspace = true +description = "Client CLI/TUI binary for Paddler" +license.workspace = true + +[[bin]] +name = "paddler_client_cli" +path = "src/main.rs" + +[dependencies] +anyhow = { workspace = true } +async-trait = { workspace = true } +clap = { workspace = true } +crossterm = { workspace = true } +env_logger = { workspace = true } +futures-util = { workspace = true } +llama-cpp-bindings-types = { workspace = true } +log = { workspace = true } +paddler_bootstrap = { workspace = true } +paddler_client = { workspace = true } +paddler_types = { workspace = true } +ratatui = { workspace = true } +reqwest = { workspace = true } +serde_json = { workspace = true } +tokio = { workspace = true } +tokio-util = { workspace = true } +url = { workspace = true } + +[lints] +workspace = true diff --git a/paddler_client_cli/examples/calculator.json b/paddler_client_cli/examples/calculator.json new file mode 100644 index 00000000..a857e795 --- /dev/null +++ b/paddler_client_cli/examples/calculator.json @@ -0,0 +1,27 @@ +{ + "type": "function", + "function": { + "name": "calculator", + "description": "Perform a basic arithmetic operation on two numbers", + "parameters": { + "type": "object", + "properties": { + "operation": { + "type": "string", + "enum": ["add", "subtract", "multiply", "divide"], + "description": "The operation to perform" + }, + "a": { + "type": "number", + "description": "First operand" + }, + "b": { + "type": "number", + "description": "Second operand" + } + }, + "required": ["operation", "a", "b"], + "additionalProperties": false + } + } +} diff --git a/paddler_client_cli/examples/get_weather.json b/paddler_client_cli/examples/get_weather.json new file mode 100644 index 00000000..d3948491 --- /dev/null +++ b/paddler_client_cli/examples/get_weather.json @@ -0,0 +1,23 @@ +{ + "type": "function", + "function": { + "name": "get_weather", + "description": "Get the current weather for a city", + "parameters": { + "type": "object", + "properties": { + "location": { + "type": "string", + "description": "City name, e.g. 'Paris' or 'Tokyo'" + }, + "unit": { + "type": "string", + "enum": ["celsius", "fahrenheit"], + "description": "Temperature unit" + } + }, + "required": ["location"], + "additionalProperties": false + } + } +} diff --git a/paddler_client_cli/examples/negotiate_with_cat.json b/paddler_client_cli/examples/negotiate_with_cat.json new file mode 100644 index 00000000..6b74c15f --- /dev/null +++ b/paddler_client_cli/examples/negotiate_with_cat.json @@ -0,0 +1,29 @@ +{ + "type": "function", + "function": { + "name": "negotiate_with_cat", + "description": "Attempt to negotiate with a cat. Outcomes are not guaranteed and may include the silent treatment.", + "parameters": { + "type": "object", + "properties": { + "topic": { + "type": "string", + "description": "What you are trying to negotiate, e.g. 'get off the keyboard' or 'stop knocking things off the table'" + }, + "bribe": { + "type": "string", + "enum": ["tuna", "salmon", "treats", "ear_scritches", "cardboard_box", "none"], + "description": "What you are offering in exchange" + }, + "desperation_level": { + "type": "integer", + "description": "How desperate you are, on a scale from 1 (mildly annoyed human) to 10 (it is 3am)", + "minimum": 1, + "maximum": 10 + } + }, + "required": ["topic"], + "additionalProperties": false + } + } +} diff --git a/paddler_client_cli/src/chat_panel_layout.rs b/paddler_client_cli/src/chat_panel_layout.rs new file mode 100644 index 00000000..022e4de1 --- /dev/null +++ b/paddler_client_cli/src/chat_panel_layout.rs @@ -0,0 +1,61 @@ +use ratatui::layout::Constraint; +use ratatui::layout::Layout; +use ratatui::layout::Position; +use ratatui::layout::Rect; + +use crate::panel_kind::PanelKind; + +const STATUS_BAR_HEIGHT: u16 = 1; + +pub struct ChatPanelLayout { + pub thinking: Rect, + pub response: Rect, + pub tool_calls: Rect, + pub undetermined: Rect, + pub status_bar: Rect, +} + +impl ChatPanelLayout { + pub fn compute(area: Rect) -> Self { + let outer = Layout::vertical([Constraint::Min(0), Constraint::Length(STATUS_BAR_HEIGHT)]) + .split(area); + let rows = Layout::vertical([Constraint::Percentage(50), Constraint::Percentage(50)]) + .split(outer[0]); + let top = Layout::horizontal([Constraint::Percentage(50), Constraint::Percentage(50)]) + .split(rows[0]); + let bottom = Layout::horizontal([Constraint::Percentage(50), Constraint::Percentage(50)]) + .split(rows[1]); + Self { + thinking: top[0], + response: top[1], + tool_calls: bottom[0], + undetermined: bottom[1], + status_bar: outer[1], + } + } + + pub const fn rect_for(&self, panel: PanelKind) -> Rect { + match panel { + PanelKind::Thinking => self.thinking, + PanelKind::Response => self.response, + PanelKind::ToolCalls => self.tool_calls, + PanelKind::Undetermined => self.undetermined, + } + } + + pub const fn viewport_rows(&self, panel: PanelKind) -> u16 { + self.rect_for(panel).height.saturating_sub(2) + } + + pub fn panel_at(&self, column: u16, row: u16) -> Option { + let position = Position { x: column, y: row }; + [ + PanelKind::Thinking, + PanelKind::Response, + PanelKind::ToolCalls, + PanelKind::Undetermined, + ] + .into_iter() + .find(|panel| self.rect_for(*panel).contains(position)) + } +} diff --git a/paddler_client_cli/src/chat_session.rs b/paddler_client_cli/src/chat_session.rs new file mode 100644 index 00000000..2ff96c2c --- /dev/null +++ b/paddler_client_cli/src/chat_session.rs @@ -0,0 +1,172 @@ +use std::io; + +use anyhow::Result; +use anyhow::anyhow; +use crossterm::event::Event as CrosstermEvent; +use crossterm::event::EventStream; +use crossterm::event::KeyCode; +use crossterm::event::KeyEvent; +use crossterm::event::KeyModifiers; +use crossterm::event::MouseButton; +use crossterm::event::MouseEvent; +use crossterm::event::MouseEventKind; +use futures_util::StreamExt; +use paddler_client::InferenceMessageStream; +use ratatui::Terminal; +use ratatui::backend::CrosstermBackend; +use ratatui::layout::Rect; +use tokio_util::sync::CancellationToken; + +use crate::chat_panel_layout::ChatPanelLayout; +use crate::chat_session_event::ChatSessionEvent; +use crate::panel_navigation::PanelNavigation; +use crate::raw_terminal_guard::RawTerminalGuard; +use crate::render_chat_panels::render_chat_panels; +use crate::streaming_response::StreamingResponse; + +const MOUSE_WHEEL_LINES: u16 = 3; +const ARROW_KEY_LINES: u16 = 1; + +pub struct ChatSession { + inference_stream: InferenceMessageStream, + state: StreamingResponse, + navigation: PanelNavigation, + shutdown: CancellationToken, +} + +impl ChatSession { + pub fn new(inference_stream: InferenceMessageStream, shutdown: CancellationToken) -> Self { + Self { + inference_stream, + state: StreamingResponse::default(), + navigation: PanelNavigation::default(), + shutdown, + } + } + + pub async fn run(mut self) -> Result<()> { + let _terminal_guard = RawTerminalGuard::enter()?; + let mut terminal = Terminal::new(CrosstermBackend::new(io::stdout()))?; + let mut events = EventStream::new(); + + let mut layout = compute_layout(&terminal)?; + terminal.draw(|frame| { + render_chat_panels(&self.state, &mut self.navigation, &layout, frame); + })?; + + loop { + match self.next_event(&mut events).await { + ChatSessionEvent::InferenceMessage(message) => { + self.state.apply_message(message); + } + ChatSessionEvent::InferenceStreamEnded => { + if !self.state.is_finished() { + self.state.record_wire_error(&anyhow!( + "inference stream ended before sending Done" + )); + } + } + ChatSessionEvent::InferenceStreamError(error) => { + self.state.record_wire_error(&error); + } + ChatSessionEvent::Key(key_event) => { + if is_quit(key_event) { + return Ok(()); + } + self.handle_navigation_key(key_event, &layout); + } + ChatSessionEvent::Mouse(mouse_event) => { + self.handle_mouse(mouse_event, &layout); + } + ChatSessionEvent::Repaint => {} + ChatSessionEvent::Shutdown => return Ok(()), + } + layout = compute_layout(&terminal)?; + terminal.draw(|frame| { + render_chat_panels(&self.state, &mut self.navigation, &layout, frame); + })?; + } + } + + async fn next_event(&mut self, events: &mut EventStream) -> ChatSessionEvent { + let inference_active = !self.state.is_finished(); + loop { + tokio::select! { + biased; + () = self.shutdown.cancelled() => return ChatSessionEvent::Shutdown, + maybe_event = events.next() => match maybe_event { + Some(Ok(CrosstermEvent::Key(key))) => return ChatSessionEvent::Key(key), + Some(Ok(CrosstermEvent::Mouse(mouse))) => return ChatSessionEvent::Mouse(mouse), + Some(Ok(CrosstermEvent::Resize(_, _))) => return ChatSessionEvent::Repaint, + Some(Ok(_)) => {} + Some(Err(read_error)) => { + log::error!("terminal event read error: {read_error}"); + return ChatSessionEvent::Shutdown; + } + None => return ChatSessionEvent::Shutdown, + }, + maybe_message = self.inference_stream.next(), if inference_active => match maybe_message { + Some(Ok(message)) => return ChatSessionEvent::InferenceMessage(message), + Some(Err(stream_error)) => return ChatSessionEvent::InferenceStreamError(stream_error.into()), + None => return ChatSessionEvent::InferenceStreamEnded, + }, + } + } + } + + fn handle_navigation_key(&mut self, key_event: KeyEvent, layout: &ChatPanelLayout) { + let focused = self.navigation.focused(); + let viewport_rows = layout.viewport_rows(focused); + let page_lines = viewport_rows.saturating_sub(1).max(1); + match key_event.code { + KeyCode::Up => self.navigation.scroll_up(focused, ARROW_KEY_LINES), + KeyCode::Down => self.navigation.scroll_down(focused, ARROW_KEY_LINES), + KeyCode::PageUp => self.navigation.scroll_up(focused, page_lines), + KeyCode::PageDown => self.navigation.scroll_down(focused, page_lines), + KeyCode::Home => self.navigation.jump_to_top(focused), + KeyCode::End => self.navigation.jump_to_bottom(focused), + KeyCode::Tab => self.navigation.cycle_focus_forward(), + KeyCode::BackTab => self.navigation.cycle_focus_backward(), + _ => {} + } + } + + fn handle_mouse(&mut self, mouse_event: MouseEvent, layout: &ChatPanelLayout) { + let Some(panel) = layout.panel_at(mouse_event.column, mouse_event.row) else { + return; + }; + match mouse_event.kind { + MouseEventKind::ScrollUp => { + self.navigation.focus(panel); + self.navigation.scroll_up(panel, MOUSE_WHEEL_LINES); + } + MouseEventKind::ScrollDown => { + self.navigation.focus(panel); + self.navigation.scroll_down(panel, MOUSE_WHEEL_LINES); + } + MouseEventKind::Down(MouseButton::Left) => { + self.navigation.focus(panel); + } + _ => {} + } + } +} + +fn compute_layout(terminal: &Terminal>) -> Result { + let size = terminal.size()?; + Ok(ChatPanelLayout::compute(Rect::new( + 0, + 0, + size.width, + size.height, + ))) +} + +const fn is_quit(key_event: KeyEvent) -> bool { + if key_event.modifiers.contains(KeyModifiers::CONTROL) + && matches!(key_event.code, KeyCode::Char('c')) + { + return true; + } + matches!(key_event.code, KeyCode::Char('q' | 'Q') | KeyCode::Esc) +} diff --git a/paddler_client_cli/src/chat_session_event.rs b/paddler_client_cli/src/chat_session_event.rs new file mode 100644 index 00000000..8c1e0edd --- /dev/null +++ b/paddler_client_cli/src/chat_session_event.rs @@ -0,0 +1,14 @@ +use crossterm::event::KeyEvent; +use crossterm::event::MouseEvent; +use paddler_types::inference_client::Message; + +#[derive(Debug)] +pub enum ChatSessionEvent { + InferenceMessage(Message), + InferenceStreamEnded, + InferenceStreamError(anyhow::Error), + Key(KeyEvent), + Mouse(MouseEvent), + Repaint, + Shutdown, +} diff --git a/paddler_client_cli/src/cmd/handler.rs b/paddler_client_cli/src/cmd/handler.rs new file mode 100644 index 00000000..2840065c --- /dev/null +++ b/paddler_client_cli/src/cmd/handler.rs @@ -0,0 +1,8 @@ +use anyhow::Result; +use async_trait::async_trait; +use tokio_util::sync::CancellationToken; + +#[async_trait] +pub trait Handler { + async fn handle(&self, shutdown: CancellationToken) -> Result<()>; +} diff --git a/paddler_client_cli/src/cmd/load_tool.rs b/paddler_client_cli/src/cmd/load_tool.rs new file mode 100644 index 00000000..e2f59d80 --- /dev/null +++ b/paddler_client_cli/src/cmd/load_tool.rs @@ -0,0 +1,17 @@ +use std::fs::File; +use std::path::Path; + +use anyhow::Context; +use anyhow::Result; +use paddler_types::request_params::continue_from_conversation_history_params::tool::Tool; +use paddler_types::request_params::continue_from_conversation_history_params::tool::tool_params::function_call::parameters_schema::raw_parameters_schema::RawParametersSchema; +use paddler_types::request_params::continue_from_conversation_history_params::tool::tool_params::function_call::parameters_schema::validated_parameters_schema::ValidatedParametersSchema; +use paddler_types::validates::Validates; + +pub fn load_tool(path: &Path) -> Result> { + let file = File::open(path).with_context(|| format!("opening tool file {}", path.display()))?; + let raw: Tool = serde_json::from_reader(file) + .with_context(|| format!("parsing tool file {}", path.display()))?; + raw.validate() + .with_context(|| format!("validating tool from {}", path.display())) +} diff --git a/paddler_client_cli/src/cmd/mod.rs b/paddler_client_cli/src/cmd/mod.rs new file mode 100644 index 00000000..973fe156 --- /dev/null +++ b/paddler_client_cli/src/cmd/mod.rs @@ -0,0 +1,5 @@ +pub mod handler; +pub mod load_tool; +pub mod prompt; +pub mod thinking_mode; +pub mod value_parser; diff --git a/paddler_client_cli/src/cmd/prompt.rs b/paddler_client_cli/src/cmd/prompt.rs new file mode 100644 index 00000000..72dcfebf --- /dev/null +++ b/paddler_client_cli/src/cmd/prompt.rs @@ -0,0 +1,73 @@ +use std::path::PathBuf; + +use anyhow::Result; +use async_trait::async_trait; +use clap::Parser; +use paddler_client::ClientInference; +use paddler_types::conversation_history::ConversationHistory; +use paddler_types::conversation_message::ConversationMessage; +use paddler_types::conversation_message_content::ConversationMessageContent; +use paddler_types::request_params::continue_from_conversation_history_params::ContinueFromConversationHistoryParams; +use reqwest::Client; +use tokio_util::sync::CancellationToken; +use url::Url; + +use super::handler::Handler; +use super::load_tool::load_tool; +use super::thinking_mode::ThinkingMode; +use super::value_parser::parse_inference_url::parse_inference_url; +use crate::chat_session::ChatSession; + +#[derive(Parser)] +pub struct Prompt { + #[arg(long, value_parser = parse_inference_url)] + /// Address of the inference server (e.g. 127.0.0.1:8061) + inference_addr: Url, + + #[arg(long)] + /// Maximum number of tokens to generate + max_tokens: i32, + + #[arg(long, value_enum)] + /// Whether chain-of-thought thinking is on or off + thinking: ThinkingMode, + + #[arg(long, action = clap::ArgAction::Append)] + /// Path to a JSON file describing one tool (repeatable) + tool: Vec, + + /// Prompt to send to the model + message: String, +} + +#[async_trait] +impl Handler for Prompt { + async fn handle(&self, shutdown: CancellationToken) -> Result<()> { + let tools = self + .tool + .iter() + .map(|path| load_tool(path)) + .collect::>>()?; + + let request = ContinueFromConversationHistoryParams { + add_generation_prompt: true, + conversation_history: ConversationHistory::new(vec![ConversationMessage { + content: ConversationMessageContent::Text(self.message.clone()), + role: "user".to_owned(), + }]), + enable_thinking: self.thinking.is_enabled(), + grammar: None, + max_tokens: self.max_tokens, + parse_tool_calls: !tools.is_empty(), + tools, + }; + + let http_client = Client::new(); + let inference = ClientInference::new(&self.inference_addr, &http_client, 1); + let stream = inference + .post_continue_from_conversation_history(&request) + .await?; + + ChatSession::new(stream, shutdown).run().await + } +} diff --git a/paddler_client_cli/src/cmd/thinking_mode.rs b/paddler_client_cli/src/cmd/thinking_mode.rs new file mode 100644 index 00000000..179cf24e --- /dev/null +++ b/paddler_client_cli/src/cmd/thinking_mode.rs @@ -0,0 +1,14 @@ +use clap::ValueEnum; + +#[derive(Clone, Copy, ValueEnum)] +pub enum ThinkingMode { + On, + Off, +} + +impl ThinkingMode { + #[must_use] + pub const fn is_enabled(self) -> bool { + matches!(self, Self::On) + } +} diff --git a/paddler_client_cli/src/cmd/value_parser/mod.rs b/paddler_client_cli/src/cmd/value_parser/mod.rs new file mode 100644 index 00000000..474b0ae5 --- /dev/null +++ b/paddler_client_cli/src/cmd/value_parser/mod.rs @@ -0,0 +1 @@ +pub mod parse_inference_url; diff --git a/paddler_client_cli/src/cmd/value_parser/parse_inference_url.rs b/paddler_client_cli/src/cmd/value_parser/parse_inference_url.rs new file mode 100644 index 00000000..b38f60d0 --- /dev/null +++ b/paddler_client_cli/src/cmd/value_parser/parse_inference_url.rs @@ -0,0 +1,6 @@ +use url::Url; + +pub fn parse_inference_url(input_addr: &str) -> Result { + Url::parse(&format!("http://{input_addr}")) + .map_err(|err| format!("invalid address '{input_addr}': {err}")) +} diff --git a/paddler_client_cli/src/main.rs b/paddler_client_cli/src/main.rs new file mode 100644 index 00000000..8ad08da0 --- /dev/null +++ b/paddler_client_cli/src/main.rs @@ -0,0 +1,50 @@ +mod chat_panel_layout; +mod chat_session; +mod chat_session_event; +mod cmd; +mod panel_kind; +mod panel_navigation; +mod raw_terminal_guard; +mod render_chat_panels; +mod stop_reason; +mod streaming_response; + +use anyhow::Result; +use clap::Parser; +use clap::Subcommand; +use cmd::handler::Handler as _; +use cmd::prompt::Prompt; +use paddler_bootstrap::shutdown_signal::wait_for_shutdown_signal; +use tokio_util::sync::CancellationToken; + +#[derive(Parser)] +#[command(arg_required_else_help(true), version, about, long_about = None)] +struct Cli { + #[command(subcommand)] + command: Commands, +} + +#[derive(Subcommand)] +enum Commands { + Prompt(Prompt), +} + +#[tokio::main] +async fn main() -> Result<()> { + env_logger::Builder::from_env(env_logger::Env::default().default_filter_or("info")).init(); + + let shutdown = CancellationToken::new(); + let signal_shutdown = shutdown.clone(); + + tokio::spawn(async move { + if let Err(error) = wait_for_shutdown_signal().await { + log::error!("shutdown signal listener failed: {error}"); + return; + } + signal_shutdown.cancel(); + }); + + match Cli::parse().command { + Commands::Prompt(handler) => handler.handle(shutdown).await, + } +} diff --git a/paddler_client_cli/src/panel_kind.rs b/paddler_client_cli/src/panel_kind.rs new file mode 100644 index 00000000..a00773cb --- /dev/null +++ b/paddler_client_cli/src/panel_kind.rs @@ -0,0 +1,19 @@ +#[repr(u8)] +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum PanelKind { + Thinking = 0, + Response = 1, + ToolCalls = 2, + Undetermined = 3, +} + +impl PanelKind { + pub const fn label(self) -> &'static str { + match self { + Self::Thinking => "Thinking", + Self::Response => "Response", + Self::ToolCalls => "Tool Calls", + Self::Undetermined => "Undetermined", + } + } +} diff --git a/paddler_client_cli/src/panel_navigation.rs b/paddler_client_cli/src/panel_navigation.rs new file mode 100644 index 00000000..086abd19 --- /dev/null +++ b/paddler_client_cli/src/panel_navigation.rs @@ -0,0 +1,180 @@ +use crate::panel_kind::PanelKind; + +const PANEL_COUNT: usize = 4; + +pub struct PanelNavigation { + focused: PanelKind, + views: [PanelView; PANEL_COUNT], +} + +#[derive(Clone, Copy)] +struct PanelView { + position: usize, + follow_bottom: bool, +} + +impl Default for PanelView { + fn default() -> Self { + Self { + position: 0, + follow_bottom: true, + } + } +} + +impl Default for PanelNavigation { + fn default() -> Self { + Self { + focused: PanelKind::Response, + views: [PanelView::default(); PANEL_COUNT], + } + } +} + +impl PanelNavigation { + pub const fn focused(&self) -> PanelKind { + self.focused + } + + pub const fn focus(&mut self, panel: PanelKind) { + self.focused = panel; + } + + pub const fn cycle_focus_forward(&mut self) { + self.focused = match self.focused { + PanelKind::Thinking => PanelKind::Response, + PanelKind::Response => PanelKind::ToolCalls, + PanelKind::ToolCalls => PanelKind::Undetermined, + PanelKind::Undetermined => PanelKind::Thinking, + }; + } + + pub const fn cycle_focus_backward(&mut self) { + self.focused = match self.focused { + PanelKind::Thinking => PanelKind::Undetermined, + PanelKind::Response => PanelKind::Thinking, + PanelKind::ToolCalls => PanelKind::Response, + PanelKind::Undetermined => PanelKind::ToolCalls, + }; + } + + pub fn scroll_up(&mut self, panel: PanelKind, lines: u16) { + let view = &mut self.views[panel as usize]; + view.follow_bottom = false; + view.position = view.position.saturating_sub(lines.into()); + } + + pub fn scroll_down(&mut self, panel: PanelKind, lines: u16) { + let view = &mut self.views[panel as usize]; + view.position = view.position.saturating_add(lines.into()); + } + + pub const fn jump_to_top(&mut self, panel: PanelKind) { + let view = &mut self.views[panel as usize]; + view.follow_bottom = false; + view.position = 0; + } + + pub const fn jump_to_bottom(&mut self, panel: PanelKind) { + self.views[panel as usize].follow_bottom = true; + } + + pub const fn settle(&mut self, panel: PanelKind, content_rows: usize, viewport_rows: usize) { + let view = &mut self.views[panel as usize]; + let max_position = content_rows.saturating_sub(viewport_rows); + if view.follow_bottom || view.position >= max_position { + view.position = max_position; + view.follow_bottom = true; + } + } + + pub const fn position(&self, panel: PanelKind) -> usize { + self.views[panel as usize].position + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn defaults_focus_response_and_follow_bottom() { + let mut nav = PanelNavigation::default(); + + assert_eq!(nav.focused(), PanelKind::Response); + nav.settle(PanelKind::Response, 100, 10); + assert_eq!(nav.position(PanelKind::Response), 90); + } + + #[test] + fn scroll_up_disengages_follow_and_decrements_position() { + let mut nav = PanelNavigation::default(); + + nav.scroll_up(PanelKind::Response, 5); + + nav.settle(PanelKind::Response, 100, 10); + assert_eq!(nav.position(PanelKind::Response), 0); + } + + #[test] + fn scroll_down_after_scroll_up_advances_within_content() { + let mut nav = PanelNavigation::default(); + nav.scroll_up(PanelKind::Response, 50); + + nav.scroll_down(PanelKind::Response, 10); + + nav.settle(PanelKind::Response, 100, 10); + assert_eq!(nav.position(PanelKind::Response), 10); + } + + #[test] + fn jump_to_bottom_re_engages_auto_follow() { + let mut nav = PanelNavigation::default(); + nav.scroll_up(PanelKind::Response, 50); + + nav.jump_to_bottom(PanelKind::Response); + + nav.settle(PanelKind::Response, 200, 10); + assert_eq!(nav.position(PanelKind::Response), 190); + } + + #[test] + fn cycle_focus_forward_walks_panels_in_reading_order() { + let mut nav = PanelNavigation::default(); + + nav.cycle_focus_forward(); + assert_eq!(nav.focused(), PanelKind::ToolCalls); + nav.cycle_focus_forward(); + assert_eq!(nav.focused(), PanelKind::Undetermined); + nav.cycle_focus_forward(); + assert_eq!(nav.focused(), PanelKind::Thinking); + nav.cycle_focus_forward(); + assert_eq!(nav.focused(), PanelKind::Response); + } + + #[test] + fn scrolling_back_to_bottom_re_engages_auto_follow_for_subsequent_growth() { + let mut nav = PanelNavigation::default(); + nav.settle(PanelKind::Response, 100, 10); + + nav.scroll_up(PanelKind::Response, 5); + nav.settle(PanelKind::Response, 100, 10); + + nav.scroll_down(PanelKind::Response, 10); + nav.settle(PanelKind::Response, 100, 10); + + nav.settle(PanelKind::Response, 110, 10); + + assert_eq!(nav.position(PanelKind::Response), 100); + } + + #[test] + fn position_is_clamped_when_content_shorter_than_stored_offset() { + let mut nav = PanelNavigation::default(); + nav.scroll_up(PanelKind::Response, 0); + nav.scroll_down(PanelKind::Response, 80); + + nav.settle(PanelKind::Response, 30, 10); + assert_eq!(nav.position(PanelKind::Response), 20); + } +} diff --git a/paddler_client_cli/src/raw_terminal_guard.rs b/paddler_client_cli/src/raw_terminal_guard.rs new file mode 100644 index 00000000..aef02c71 --- /dev/null +++ b/paddler_client_cli/src/raw_terminal_guard.rs @@ -0,0 +1,57 @@ +use std::io; + +use anyhow::Context; +use anyhow::Result; +use crossterm::ExecutableCommand; +use crossterm::event::DisableMouseCapture; +use crossterm::event::EnableMouseCapture; +use crossterm::terminal::EnterAlternateScreen; +use crossterm::terminal::LeaveAlternateScreen; +use crossterm::terminal::disable_raw_mode; +use crossterm::terminal::enable_raw_mode; + +pub struct RawTerminalGuard; + +impl RawTerminalGuard { + pub fn enter() -> Result { + enable_raw_mode().context("enabling raw mode")?; + if let Err(enter_alt_screen_error) = io::stdout().execute(EnterAlternateScreen) { + if let Err(rollback_error) = disable_raw_mode() { + log::error!( + "failed to disable raw mode while rolling back alt-screen entry: {rollback_error}" + ); + } + return Err( + anyhow::Error::from(enter_alt_screen_error).context("entering alternate screen") + ); + } + if let Err(enable_mouse_error) = io::stdout().execute(EnableMouseCapture) { + if let Err(leave_alt_screen_error) = io::stdout().execute(LeaveAlternateScreen) { + log::error!( + "failed to leave alt screen while rolling back mouse-capture: {leave_alt_screen_error}" + ); + } + if let Err(rollback_error) = disable_raw_mode() { + log::error!( + "failed to disable raw mode while rolling back mouse-capture: {rollback_error}" + ); + } + return Err(anyhow::Error::from(enable_mouse_error).context("enabling mouse capture")); + } + Ok(Self) + } +} + +impl Drop for RawTerminalGuard { + fn drop(&mut self) { + if let Err(disable_mouse_error) = io::stdout().execute(DisableMouseCapture) { + log::error!("failed to disable mouse capture: {disable_mouse_error}"); + } + if let Err(leave_alt_screen_error) = io::stdout().execute(LeaveAlternateScreen) { + log::error!("failed to leave alternate screen: {leave_alt_screen_error}"); + } + if let Err(disable_raw_mode_error) = disable_raw_mode() { + log::error!("failed to disable raw mode: {disable_raw_mode_error}"); + } + } +} diff --git a/paddler_client_cli/src/render_chat_panels.rs b/paddler_client_cli/src/render_chat_panels.rs new file mode 100644 index 00000000..0b73b105 --- /dev/null +++ b/paddler_client_cli/src/render_chat_panels.rs @@ -0,0 +1,262 @@ +use llama_cpp_bindings_types::ParsedToolCall; +use llama_cpp_bindings_types::ToolCallArguments; +use paddler_types::generation_summary::GenerationSummary; +use ratatui::Frame; +use ratatui::layout::Margin; +use ratatui::layout::Rect; +use ratatui::widgets::Block; +use ratatui::widgets::Paragraph; +use ratatui::widgets::Scrollbar; +use ratatui::widgets::ScrollbarOrientation; +use ratatui::widgets::ScrollbarState; +use ratatui::widgets::Wrap; + +use crate::chat_panel_layout::ChatPanelLayout; +use crate::panel_kind::PanelKind; +use crate::panel_navigation::PanelNavigation; +use crate::streaming_response::StreamingResponse; + +pub fn render_chat_panels( + state: &StreamingResponse, + navigation: &mut PanelNavigation, + layout: &ChatPanelLayout, + frame: &mut Frame<'_>, +) { + render_text_panel( + frame, + layout.thinking, + PanelKind::Thinking, + &state.thinking, + navigation, + ); + render_text_panel( + frame, + layout.response, + PanelKind::Response, + &state.response, + navigation, + ); + let formatted_tool_calls = + format_tool_calls(&state.tool_calls, &state.pending_tool_call_buffer); + render_text_panel( + frame, + layout.tool_calls, + PanelKind::ToolCalls, + &formatted_tool_calls, + navigation, + ); + render_text_panel( + frame, + layout.undetermined, + PanelKind::Undetermined, + &state.undetermined, + navigation, + ); + render_status_bar(frame, layout.status_bar, state); +} + +fn render_text_panel( + frame: &mut Frame<'_>, + area: Rect, + panel: PanelKind, + buffer: &str, + navigation: &mut PanelNavigation, +) { + let title = if navigation.focused() == panel { + format!("[ {} ]", panel.label()) + } else { + format!(" {} ", panel.label()) + }; + let block = Block::bordered().title(title); + let inner = block.inner(area); + let visible_rows = visible_row_count(buffer, inner.width); + navigation.settle(panel, visible_rows.into(), inner.height.into()); + let position = navigation.position(panel); + let scroll_offset = u16::try_from(position).unwrap_or(u16::MAX); + + let paragraph = Paragraph::new(buffer) + .wrap(Wrap { trim: false }) + .scroll((scroll_offset, 0)) + .block(block); + frame.render_widget(paragraph, area); + + if visible_rows > inner.height { + let mut scrollbar_state = ScrollbarState::new(visible_rows.into()) + .position(position) + .viewport_content_length(inner.height.into()); + let scrollbar = Scrollbar::new(ScrollbarOrientation::VerticalRight) + .thumb_symbol("┃") + .track_symbol(Some("│")) + .begin_symbol(None) + .end_symbol(None); + frame.render_stateful_widget( + scrollbar, + area.inner(Margin { + vertical: 1, + horizontal: 0, + }), + &mut scrollbar_state, + ); + } +} + +fn render_status_bar(frame: &mut Frame<'_>, area: Rect, state: &StreamingResponse) { + let text = match (&state.stop_reason, &state.summary) { + (None, _) => { + "generating… · tab/shift-tab focus · ↑↓ pgup/pgdn home/end scroll · q quit".to_owned() + } + (Some(_), Some(summary)) => format_completion_status(summary), + (Some(reason), None) => format!("stopped — {reason} · press q to quit"), + }; + frame.render_widget(Paragraph::new(text), area); +} + +fn format_tool_calls(parsed_calls: &[ParsedToolCall], pending_raw: &str) -> String { + let mut output = String::new(); + for call in parsed_calls { + output.push_str(&call.name); + output.push('\n'); + match &call.arguments { + ToolCallArguments::ValidJson(value) => match serde_json::to_string_pretty(value) { + Ok(formatted) => { + for line in formatted.lines() { + output.push_str(" "); + output.push_str(line); + output.push('\n'); + } + } + Err(format_error) => { + log::error!( + "failed to pretty-print tool-call arguments for {name}: {format_error}", + name = call.name + ); + output.push_str(" "); + output.push_str(&value.to_string()); + output.push('\n'); + } + }, + ToolCallArguments::InvalidJson(raw) => { + output.push_str(" invalid JSON: "); + output.push_str(raw); + output.push('\n'); + } + } + } + if !pending_raw.is_empty() { + output.push_str(pending_raw); + } + output +} + +fn format_completion_status(summary: &GenerationSummary) -> String { + let usage = summary.usage; + format!( + "done · response {response} · thinking {thinking} · tools {tools} · undet {undet} · prompt {prompt} · total {total} · press q to quit", + response = usage.content_tokens, + thinking = usage.reasoning_tokens, + tools = usage.tool_call_tokens, + undet = usage.undeterminable_tokens, + prompt = usage.prompt_tokens, + total = usage.total_tokens(), + ) +} + +fn visible_row_count(buffer: &str, width: u16) -> u16 { + if width == 0 || buffer.is_empty() { + return 0; + } + let mut total: u16 = 0; + for line in buffer.split('\n') { + let char_count = u16::try_from(line.chars().count()).unwrap_or(u16::MAX); + let rows = char_count.div_ceil(width).max(1); + total = total.saturating_add(rows); + } + total +} + +#[cfg(test)] +mod tests { + use anyhow::Result; + use paddler_types::generation_summary::GenerationSummary; + use ratatui::Terminal; + use ratatui::backend::TestBackend; + use ratatui::buffer::Buffer; + + use super::*; + use crate::stop_reason::StopReason; + + fn render_to_string(state: &StreamingResponse, width: u16, height: u16) -> Result { + let mut navigation = PanelNavigation::default(); + let mut terminal = Terminal::new(TestBackend::new(width, height))?; + terminal.draw(|frame| { + let layout = ChatPanelLayout::compute(frame.area()); + render_chat_panels(state, &mut navigation, &layout, frame); + })?; + Ok(buffer_text(terminal.backend().buffer())) + } + + fn buffer_text(buffer: &Buffer) -> String { + let area = buffer.area; + let mut output = String::with_capacity((area.width as usize + 1) * area.height as usize); + for y in 0..area.height { + for x in 0..area.width { + output.push_str(buffer[(x, y)].symbol()); + } + output.push('\n'); + } + output + } + + #[test] + fn empty_state_shows_all_four_panels_and_generating_status() -> Result<()> { + let state = StreamingResponse::default(); + + let rendered = render_to_string(&state, 100, 30)?; + + assert!(rendered.contains("Thinking")); + assert!(rendered.contains("Response")); + assert!(rendered.contains("Tool Calls")); + assert!(rendered.contains("Undetermined")); + assert!(rendered.contains("generating")); + assert!(!rendered.contains("done")); + Ok(()) + } + + #[test] + fn focused_panel_title_uses_brackets() -> Result<()> { + let state = StreamingResponse::default(); + + let rendered = render_to_string(&state, 100, 30)?; + + assert!(rendered.contains("[ Response ]")); + assert!(rendered.contains(" Thinking ")); + Ok(()) + } + + #[test] + fn response_buffer_text_is_visible() -> Result<()> { + let mut state = StreamingResponse::default(); + state.response.push_str("hello world"); + + let rendered = render_to_string(&state, 80, 30)?; + + assert!(rendered.contains("hello world")); + Ok(()) + } + + #[test] + fn completed_state_shows_summary_and_quit_hint() -> Result<()> { + let state = StreamingResponse { + summary: Some(GenerationSummary::default()), + stop_reason: Some(StopReason::Completed), + ..StreamingResponse::default() + }; + + let rendered = render_to_string(&state, 140, 30)?; + + assert!(rendered.contains("done")); + assert!(rendered.contains("press q to quit")); + assert!(!rendered.contains("generating")); + Ok(()) + } +} diff --git a/paddler_client_cli/src/stop_reason.rs b/paddler_client_cli/src/stop_reason.rs new file mode 100644 index 00000000..ab718014 --- /dev/null +++ b/paddler_client_cli/src/stop_reason.rs @@ -0,0 +1,74 @@ +use std::fmt; + +#[derive(Debug)] +pub enum StopReason { + Completed, + ChatTemplateError(String), + GrammarIncompatibleWithThinking(String), + GrammarInitializationFailed(String), + GrammarRejectedModelOutput(String), + GrammarSyntaxError(String), + ImageDecodingFailed(String), + InferenceError { code: i32, description: String }, + MultimodalNotSupported(String), + SamplerError(String), + Timeout, + TooManyBufferedRequests, + ToolCallParseFailed(String), + ToolCallValidationFailed(Vec), + ToolSchemaInvalid(String), + WireStreamError(String), +} + +impl fmt::Display for StopReason { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Completed => formatter.write_str("completed"), + Self::ChatTemplateError(detail) => { + write!(formatter, "chat template error: {detail}") + } + Self::GrammarIncompatibleWithThinking(detail) => { + write!(formatter, "grammar incompatible with thinking: {detail}") + } + Self::GrammarInitializationFailed(detail) => { + write!(formatter, "grammar initialization failed: {detail}") + } + Self::GrammarRejectedModelOutput(detail) => { + write!(formatter, "grammar rejected model output: {detail}") + } + Self::GrammarSyntaxError(detail) => { + write!(formatter, "grammar syntax error: {detail}") + } + Self::ImageDecodingFailed(detail) => { + write!(formatter, "image decoding failed: {detail}") + } + Self::InferenceError { code, description } => { + write!(formatter, "inference error {code}: {description}") + } + Self::MultimodalNotSupported(detail) => { + write!(formatter, "multimodal input not supported: {detail}") + } + Self::SamplerError(detail) => write!(formatter, "sampler error: {detail}"), + Self::Timeout => formatter.write_str("balancer timed out the request"), + Self::TooManyBufferedRequests => { + formatter.write_str("balancer rejected the request: queue is full") + } + Self::ToolCallParseFailed(detail) => { + write!(formatter, "tool-call parse failed: {detail}") + } + Self::ToolCallValidationFailed(field_errors) => { + write!( + formatter, + "tool-call validation failed: {}", + field_errors.join("; ") + ) + } + Self::ToolSchemaInvalid(detail) => { + write!(formatter, "tool schema invalid: {detail}") + } + Self::WireStreamError(detail) => { + write!(formatter, "wire stream error: {detail}") + } + } + } +} diff --git a/paddler_client_cli/src/streaming_response.rs b/paddler_client_cli/src/streaming_response.rs new file mode 100644 index 00000000..3fcd6c85 --- /dev/null +++ b/paddler_client_cli/src/streaming_response.rs @@ -0,0 +1,214 @@ +use llama_cpp_bindings_types::ParsedToolCall; +use paddler_types::generated_token_result::GeneratedTokenResult; +use paddler_types::generation_summary::GenerationSummary; +use paddler_types::inference_client::Message; +use paddler_types::inference_client::Response; + +use crate::stop_reason::StopReason; + +#[derive(Debug, Default)] +pub struct StreamingResponse { + pub thinking: String, + pub response: String, + pub tool_calls: Vec, + pub pending_tool_call_buffer: String, + pub undetermined: String, + pub summary: Option, + pub stop_reason: Option, +} + +impl StreamingResponse { + pub fn apply_message(&mut self, message: Message) { + match message { + Message::Error(envelope) => { + self.stop_reason = Some(StopReason::InferenceError { + code: envelope.error.code, + description: envelope.error.description, + }); + } + Message::Response(envelope) => self.apply_response(envelope.response), + } + } + + pub fn record_wire_error(&mut self, error: &anyhow::Error) { + self.stop_reason = Some(StopReason::WireStreamError(error.to_string())); + } + + pub const fn is_finished(&self) -> bool { + self.stop_reason.is_some() + } + + fn apply_response(&mut self, response: Response) { + match response { + Response::GeneratedToken(token_result) => self.apply_token_result(token_result), + Response::Timeout => { + self.stop_reason = Some(StopReason::Timeout); + } + Response::TooManyBufferedRequests => { + self.stop_reason = Some(StopReason::TooManyBufferedRequests); + } + Response::Embedding(_) => { + unreachable!("server sent an embedding response on a token-generation stream") + } + } + } + + fn apply_token_result(&mut self, token_result: GeneratedTokenResult) { + match token_result { + GeneratedTokenResult::ContentToken(piece) => self.response.push_str(&piece), + GeneratedTokenResult::ReasoningToken(piece) => self.thinking.push_str(&piece), + GeneratedTokenResult::UndeterminableToken(piece) => self.undetermined.push_str(&piece), + GeneratedTokenResult::ToolCallToken(piece) => { + self.pending_tool_call_buffer.push_str(&piece); + } + GeneratedTokenResult::ToolCallParsed(calls) => { + self.pending_tool_call_buffer.clear(); + self.tool_calls.extend(calls); + } + GeneratedTokenResult::Done(summary) => { + self.summary = Some(summary); + self.stop_reason = Some(StopReason::Completed); + } + GeneratedTokenResult::ChatTemplateError(detail) => { + self.stop_reason = Some(StopReason::ChatTemplateError(detail)); + } + GeneratedTokenResult::GrammarIncompatibleWithThinking(detail) => { + self.stop_reason = Some(StopReason::GrammarIncompatibleWithThinking(detail)); + } + GeneratedTokenResult::GrammarInitializationFailed(detail) => { + self.stop_reason = Some(StopReason::GrammarInitializationFailed(detail)); + } + GeneratedTokenResult::GrammarRejectedModelOutput(detail) => { + self.stop_reason = Some(StopReason::GrammarRejectedModelOutput(detail)); + } + GeneratedTokenResult::GrammarSyntaxError(detail) => { + self.stop_reason = Some(StopReason::GrammarSyntaxError(detail)); + } + GeneratedTokenResult::ImageDecodingFailed(detail) => { + self.stop_reason = Some(StopReason::ImageDecodingFailed(detail)); + } + GeneratedTokenResult::MultimodalNotSupported(detail) => { + self.stop_reason = Some(StopReason::MultimodalNotSupported(detail)); + } + GeneratedTokenResult::SamplerError(detail) => { + self.stop_reason = Some(StopReason::SamplerError(detail)); + } + GeneratedTokenResult::ToolCallParseFailed(detail) => { + self.stop_reason = Some(StopReason::ToolCallParseFailed(detail)); + } + GeneratedTokenResult::ToolCallValidationFailed(field_errors) => { + self.stop_reason = Some(StopReason::ToolCallValidationFailed(field_errors)); + } + GeneratedTokenResult::ToolSchemaInvalid(detail) => { + self.stop_reason = Some(StopReason::ToolSchemaInvalid(detail)); + } + } + } +} + +#[cfg(test)] +mod tests { + use anyhow::anyhow; + use paddler_types::jsonrpc::Error; + use paddler_types::jsonrpc::ErrorEnvelope; + use paddler_types::jsonrpc::ResponseEnvelope; + + use super::*; + + fn token_message(token_result: GeneratedTokenResult) -> Message { + Message::Response(ResponseEnvelope { + request_id: "test-request".to_owned(), + response: Response::GeneratedToken(token_result), + }) + } + + #[test] + fn content_token_appends_to_response_buffer() { + let mut state = StreamingResponse::default(); + + state.apply_message(token_message(GeneratedTokenResult::ContentToken( + "hello ".to_owned(), + ))); + state.apply_message(token_message(GeneratedTokenResult::ContentToken( + "world".to_owned(), + ))); + + assert_eq!(state.response, "hello world"); + assert!(state.thinking.is_empty()); + assert!(state.undetermined.is_empty()); + assert!(!state.is_finished()); + } + + #[test] + fn raw_tool_call_token_appends_to_pending_buffer() { + let mut state = StreamingResponse::default(); + + state.apply_message(token_message(GeneratedTokenResult::ToolCallToken( + "{\"name\":".to_owned(), + ))); + state.apply_message(token_message(GeneratedTokenResult::ToolCallToken( + "\"calc\"}".to_owned(), + ))); + + assert_eq!(state.pending_tool_call_buffer, "{\"name\":\"calc\"}"); + assert!(state.tool_calls.is_empty()); + } + + #[test] + fn tool_call_parsed_replaces_pending_buffer_with_structured_calls() { + let mut state = StreamingResponse::default(); + state.apply_message(token_message(GeneratedTokenResult::ToolCallToken( + "{\"name\":\"calc\"}".to_owned(), + ))); + let parsed = vec![ParsedToolCall::default()]; + + state.apply_message(token_message(GeneratedTokenResult::ToolCallParsed( + parsed.clone(), + ))); + + assert_eq!(state.tool_calls, parsed); + assert!(state.pending_tool_call_buffer.is_empty()); + } + + #[test] + fn done_records_summary_and_completed_stop_reason() { + let mut state = StreamingResponse::default(); + let summary = GenerationSummary::default(); + + state.apply_message(token_message(GeneratedTokenResult::Done(summary))); + + assert!(state.summary.is_some()); + assert!(matches!(state.stop_reason, Some(StopReason::Completed))); + assert!(state.is_finished()); + } + + #[test] + fn message_error_sets_inference_error_stop_reason() { + let mut state = StreamingResponse::default(); + + state.apply_message(Message::Error(ErrorEnvelope { + request_id: "test-request".to_owned(), + error: Error { + code: 503, + description: "agent unavailable".to_owned(), + }, + })); + + assert!(matches!( + state.stop_reason, + Some(StopReason::InferenceError { code: 503, .. }) + )); + } + + #[test] + fn wire_error_sets_wire_stream_error_stop_reason() { + let mut state = StreamingResponse::default(); + + state.record_wire_error(&anyhow!("connection reset")); + + assert!(matches!( + state.stop_reason, + Some(StopReason::WireStreamError(ref message)) if message.contains("connection reset") + )); + } +} From 14464a3beac2c7683967b76b36e1a8276da46dac Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ma=C5=82gorzata=20Zagajewska?= Date: Sat, 9 May 2026 03:37:16 +0200 Subject: [PATCH 26/51] Fix template-swap drain race with RAII slot guard; consolidate model resolution --- ...tinue_from_conversation_history_request.rs | 7 + .../agent/continue_from_raw_prompt_request.rs | 7 + .../agent/continuous_batch_active_request.rs | 2 + paddler/src/agent/continuous_batch_arbiter.rs | 32 +++- .../continuous_batch_arbiter_build_outcome.rs | 6 + .../continuous_batch_embedding_processor.rs | 7 + .../agent/continuous_batch_scheduler/mod.rs | 41 +++-- paddler/src/agent/from_request_params.rs | 4 + .../agent/generate_embedding_batch_request.rs | 7 + paddler/src/agent/llamacpp_arbiter_service.rs | 159 +++++------------ .../agent/management_socket_client_service.rs | 8 + paddler/src/agent/mod.rs | 2 + paddler/src/agent/reconciliation_service.rs | 6 +- paddler/src/agent/slot_guard.rs | 106 ++++++++++++ paddler/src/agent_desired_model.rs | 147 ---------------- paddler/src/agent_desired_state.rs | 162 ++++++++++++++++-- .../src/balancer/buffered_request_manager.rs | 3 +- .../src/balancer/reconciliation_service.rs | 16 +- paddler/src/balancer_desired_state.rs | 9 +- .../chat_template_renderer/pyjinja_tojson.rs | 20 +-- paddler/src/converts_to_applicable_state.rs | 5 +- paddler/src/desired_model_resolution.rs | 7 + paddler/src/download_huggingface_model.rs | 125 ++++++++++++++ paddler/src/lib.rs | 4 +- paddler/src/resolve_desired_model.rs | 103 +++++++++++ paddler/src/tool_call_pipeline.rs | 5 +- 26 files changed, 653 insertions(+), 347 deletions(-) create mode 100644 paddler/src/agent/continuous_batch_arbiter_build_outcome.rs create mode 100644 paddler/src/agent/slot_guard.rs delete mode 100644 paddler/src/agent_desired_model.rs create mode 100644 paddler/src/desired_model_resolution.rs create mode 100644 paddler/src/download_huggingface_model.rs create mode 100644 paddler/src/resolve_desired_model.rs diff --git a/paddler/src/agent/continue_from_conversation_history_request.rs b/paddler/src/agent/continue_from_conversation_history_request.rs index 6bbcec8e..1b16aa6b 100644 --- a/paddler/src/agent/continue_from_conversation_history_request.rs +++ b/paddler/src/agent/continue_from_conversation_history_request.rs @@ -1,14 +1,19 @@ +use std::sync::Arc; + use paddler_types::generated_token_result::GeneratedTokenResult; use paddler_types::request_params::continue_from_conversation_history_params::ContinueFromConversationHistoryParams; use paddler_types::request_params::continue_from_conversation_history_params::tool::tool_params::function_call::parameters_schema::validated_parameters_schema::ValidatedParametersSchema; use tokio::sync::mpsc; use crate::agent::from_request_params::FromRequestParams; +use crate::agent::slot_guard::SlotGuard; +use crate::slot_aggregated_status::SlotAggregatedStatus; pub struct ContinueFromConversationHistoryRequest { pub generate_tokens_stop_rx: mpsc::UnboundedReceiver<()>, pub generated_tokens_tx: mpsc::UnboundedSender, pub params: ContinueFromConversationHistoryParams, + pub slot_guard: SlotGuard, } impl FromRequestParams for ContinueFromConversationHistoryRequest { @@ -19,11 +24,13 @@ impl FromRequestParams for ContinueFromConversationHistoryRequest { params: Self::RequestParams, generated_tokens_tx: mpsc::UnboundedSender, generate_tokens_stop_rx: mpsc::UnboundedReceiver<()>, + slot_aggregated_status: Arc, ) -> Self { Self { generate_tokens_stop_rx, generated_tokens_tx, params, + slot_guard: SlotGuard::new(slot_aggregated_status), } } } diff --git a/paddler/src/agent/continue_from_raw_prompt_request.rs b/paddler/src/agent/continue_from_raw_prompt_request.rs index f6365ae1..9f573e74 100644 --- a/paddler/src/agent/continue_from_raw_prompt_request.rs +++ b/paddler/src/agent/continue_from_raw_prompt_request.rs @@ -1,13 +1,18 @@ +use std::sync::Arc; + use paddler_types::generated_token_result::GeneratedTokenResult; use paddler_types::request_params::ContinueFromRawPromptParams; use tokio::sync::mpsc; use crate::agent::from_request_params::FromRequestParams; +use crate::agent::slot_guard::SlotGuard; +use crate::slot_aggregated_status::SlotAggregatedStatus; pub struct ContinueFromRawPromptRequest { pub generate_tokens_stop_rx: mpsc::UnboundedReceiver<()>, pub generated_tokens_tx: mpsc::UnboundedSender, pub params: ContinueFromRawPromptParams, + pub slot_guard: SlotGuard, } impl FromRequestParams for ContinueFromRawPromptRequest { @@ -18,11 +23,13 @@ impl FromRequestParams for ContinueFromRawPromptRequest { params: Self::RequestParams, generated_tokens_tx: mpsc::UnboundedSender, generate_tokens_stop_rx: mpsc::UnboundedReceiver<()>, + slot_aggregated_status: Arc, ) -> Self { Self { generate_tokens_stop_rx, generated_tokens_tx, params, + slot_guard: SlotGuard::new(slot_aggregated_status), } } } diff --git a/paddler/src/agent/continuous_batch_active_request.rs b/paddler/src/agent/continuous_batch_active_request.rs index b25292e9..13d239ef 100644 --- a/paddler/src/agent/continuous_batch_active_request.rs +++ b/paddler/src/agent/continuous_batch_active_request.rs @@ -8,6 +8,7 @@ use tokio::sync::mpsc; use tokio::sync::mpsc::error::TryRecvError; use crate::agent::continuous_batch_request_phase::ContinuousBatchRequestPhase; +use crate::agent::slot_guard::SlotGuard; use crate::tool_call_pipeline::ToolCallPipeline; pub struct ContinuousBatchActiveRequest { @@ -24,6 +25,7 @@ pub struct ContinuousBatchActiveRequest { pub prompt_tokens: Vec, pub prompt_tokens_ingested: usize, pub sequence_id: i32, + pub slot_guard: SlotGuard, pub tool_call_pipeline: Option, } diff --git a/paddler/src/agent/continuous_batch_arbiter.rs b/paddler/src/agent/continuous_batch_arbiter.rs index 618cab06..979d41f4 100644 --- a/paddler/src/agent/continuous_batch_arbiter.rs +++ b/paddler/src/agent/continuous_batch_arbiter.rs @@ -27,10 +27,12 @@ use paddler_types::inference_parameters::InferenceParameters; use paddler_types::model_metadata::ModelMetadata; use tokio::sync::oneshot; +use crate::agent::continuous_batch_arbiter_build_outcome::ContinuousBatchArbiterBuildOutcome; use crate::agent::continuous_batch_arbiter_handle::ContinuousBatchArbiterHandle; use crate::agent::continuous_batch_scheduler::ContinuousBatchScheduler; use crate::agent::continuous_batch_scheduler_context::ContinuousBatchSchedulerContext; use crate::agent::model_metadata_holder::ModelMetadataHolder; +use crate::agent_applicable_state::AgentApplicableState; use crate::agent_issue_fix::AgentIssueFix; use crate::chat_template_renderer::ChatTemplateRenderer; use crate::converts_to_llama_kv_cache_dtype::ConvertsToLlamaKvCacheDtype; @@ -50,6 +52,33 @@ pub struct ContinuousBatchArbiter { } impl ContinuousBatchArbiter { + #[must_use] + pub fn build_from_applicable_state( + applicable_state: AgentApplicableState, + agent_name: Option, + desired_slots_total: i32, + model_metadata_holder: Arc, + slot_aggregated_status_manager: Arc, + ) -> ContinuousBatchArbiterBuildOutcome { + let Some(model_path) = applicable_state.model_path else { + return ContinuousBatchArbiterBuildOutcome::NoModelConfigured; + }; + + let model_path_string = model_path.display().to_string(); + + ContinuousBatchArbiterBuildOutcome::ReadyToSpawn(Self { + agent_name, + chat_template_override: applicable_state.chat_template_override, + desired_slots_total, + inference_parameters: applicable_state.inference_parameters, + multimodal_projection_path: applicable_state.multimodal_projection_path, + model_metadata_holder, + model_path, + model_path_string, + slot_aggregated_status_manager, + }) + } + pub async fn spawn(&self) -> Result { let (chat_template_loaded_tx, chat_template_loaded_rx) = oneshot::channel::<()>(); let (model_loaded_tx, model_loaded_rx) = oneshot::channel::<()>(); @@ -315,9 +344,6 @@ impl ContinuousBatchArbiter { scheduler_context, llama_context, desired_slots_total, - slot_aggregated_status_manager - .slot_aggregated_status - .clone(), ); scheduler.run(); diff --git a/paddler/src/agent/continuous_batch_arbiter_build_outcome.rs b/paddler/src/agent/continuous_batch_arbiter_build_outcome.rs new file mode 100644 index 00000000..84b084ad --- /dev/null +++ b/paddler/src/agent/continuous_batch_arbiter_build_outcome.rs @@ -0,0 +1,6 @@ +use crate::agent::continuous_batch_arbiter::ContinuousBatchArbiter; + +pub enum ContinuousBatchArbiterBuildOutcome { + NoModelConfigured, + ReadyToSpawn(ContinuousBatchArbiter), +} diff --git a/paddler/src/agent/continuous_batch_embedding_processor.rs b/paddler/src/agent/continuous_batch_embedding_processor.rs index 162023c5..c6fe1402 100644 --- a/paddler/src/agent/continuous_batch_embedding_processor.rs +++ b/paddler/src/agent/continuous_batch_embedding_processor.rs @@ -43,8 +43,15 @@ impl<'context> ContinuousBatchEmbeddingProcessor<'context> { input_batch, normalization_method, }, + slot_guard, }: GenerateEmbeddingBatchRequest, ) -> Result<()> { + #[expect( + unused_variables, + reason = "slot_guard is held until function returns to release the slot via Drop" + )] + let slot_guard = slot_guard; + if !self .scheduler_context .inference_parameters diff --git a/paddler/src/agent/continuous_batch_scheduler/mod.rs b/paddler/src/agent/continuous_batch_scheduler/mod.rs index c36810c3..5006936f 100644 --- a/paddler/src/agent/continuous_batch_scheduler/mod.rs +++ b/paddler/src/agent/continuous_batch_scheduler/mod.rs @@ -68,9 +68,8 @@ use crate::agent::resolve_grammar::resolve_grammar; use crate::agent::sample_token_at_batch_index::sample_token_at_batch_index; use crate::agent::sampling_outcome::SamplingOutcome; use crate::agent::sequence_id_pool::SequenceIdPool; +use crate::agent::slot_guard::SlotGuard; use crate::decoded_image::DecodedImage; -use crate::dispenses_slots::DispensesSlots; -use crate::slot_aggregated_status::SlotAggregatedStatus; use crate::tool_call_pipeline::ToolCallPipeline; use crate::tool_call_validator::ToolCallValidator; use crate::tool_call_validator::ValidatorBuildError; @@ -84,10 +83,10 @@ pub struct ContinuousBatchScheduler { running: bool, scheduler_context: Arc, sequence_id_pool: SequenceIdPool, - slot_aggregated_status: Arc, } impl ContinuousBatchScheduler { + #[must_use] #[expect( unsafe_code, reason = "required for FFI lifetime extension with llama.cpp" @@ -97,7 +96,6 @@ impl ContinuousBatchScheduler { scheduler_context: Arc, llama_context: LlamaContext, max_concurrent_sequences: i32, - slot_aggregated_status: Arc, ) -> Self { let llama_context = unsafe { std::mem::transmute::, LlamaContext<'static>>(llama_context) @@ -112,7 +110,6 @@ impl ContinuousBatchScheduler { running: true, scheduler_context, sequence_id_pool: SequenceIdPool::new(max_concurrent_sequences), - slot_aggregated_status, } } @@ -196,13 +193,15 @@ impl ContinuousBatchScheduler { fn accept_conversation_history_request( &mut self, - request: ContinueFromConversationHistoryRequest, + ContinueFromConversationHistoryRequest { + generate_tokens_stop_rx, + generated_tokens_tx, + params, + slot_guard, + }: ContinueFromConversationHistoryRequest, ) { - let generated_tokens_tx = request.generated_tokens_tx; - let generate_tokens_stop_rx = request.generate_tokens_stop_rx; - let prepared = match prepare_conversation_history_request( - request.params, + params, &generated_tokens_tx, &self.scheduler_context, ) { @@ -233,6 +232,7 @@ impl ContinuousBatchScheduler { tools, generated_tokens_tx, generate_tokens_stop_rx, + slot_guard, ) { error!( "{:?}: failed to accept text prompt: {err:#}", @@ -261,6 +261,7 @@ impl ContinuousBatchScheduler { tools, generated_tokens_tx, generate_tokens_stop_rx, + slot_guard, ) { error!( @@ -283,6 +284,7 @@ impl ContinuousBatchScheduler { max_tokens, raw_prompt, }, + slot_guard, }: ContinueFromRawPromptRequest, ) { let grammar_sampler = match resolve_grammar(grammar.as_ref(), false, &generated_tokens_tx) { @@ -305,6 +307,7 @@ impl ContinuousBatchScheduler { Vec::new(), generated_tokens_tx, generate_tokens_stop_rx, + slot_guard, ) { error!( "{:?}: failed to accept raw prompt: {err:#}", @@ -413,12 +416,9 @@ impl ContinuousBatchScheduler { .collect::, _>>() .context("failed to serialize tools to JSON")?; - let pipeline = ToolCallPipeline::new( - self.scheduler_context.model.clone(), - &tools_json, - validator, - ) - .context("failed to serialize tools for tool-call pipeline")?; + let pipeline = + ToolCallPipeline::new(self.scheduler_context.model.clone(), &tools_json, validator) + .context("failed to serialize tools for tool-call pipeline")?; Ok(ToolCallPipelineBuildOutcome::Ready(pipeline)) } @@ -436,6 +436,7 @@ impl ContinuousBatchScheduler { tools: Vec>, generated_tokens_tx: mpsc::UnboundedSender, generate_tokens_stop_rx: mpsc::UnboundedReceiver<()>, + slot_guard: SlotGuard, ) -> Result<()> { let tool_call_pipeline = match self .build_tool_call_pipeline(tools, parse_tool_calls) @@ -549,8 +550,6 @@ impl ContinuousBatchScheduler { ); } - self.slot_aggregated_status.take_slot(); - debug!( "{:?}: accepted text prompt request on sequence {sequence_id} ({} tokens)", self.scheduler_context.agent_name, @@ -571,6 +570,7 @@ impl ContinuousBatchScheduler { prompt_tokens, prompt_tokens_ingested: 0, sequence_id, + slot_guard, tool_call_pipeline, }); @@ -592,6 +592,7 @@ impl ContinuousBatchScheduler { tools: Vec>, generated_tokens_tx: mpsc::UnboundedSender, generate_tokens_stop_rx: mpsc::UnboundedReceiver<()>, + slot_guard: SlotGuard, ) -> Result<()> { let tool_call_pipeline = match self .build_tool_call_pipeline(tools, parse_tool_calls) @@ -781,8 +782,6 @@ impl ContinuousBatchScheduler { let chain = self.create_sampler_chain(); - self.slot_aggregated_status.take_slot(); - debug!( "{:?}: accepted multimodal request on sequence {sequence_id} ({tokens_ingested} tokens ingested)", self.scheduler_context.agent_name @@ -802,6 +801,7 @@ impl ContinuousBatchScheduler { prompt_tokens: Vec::new(), prompt_tokens_ingested: 0, sequence_id, + slot_guard, tool_call_pipeline, }); @@ -1075,7 +1075,6 @@ impl ContinuousBatchScheduler { } self.sequence_id_pool.release(removed_request.sequence_id); - self.slot_aggregated_status.release_slot(); let usage = removed_request.token_classifier.usage(); diff --git a/paddler/src/agent/from_request_params.rs b/paddler/src/agent/from_request_params.rs index 6e7d79b4..db9371d2 100644 --- a/paddler/src/agent/from_request_params.rs +++ b/paddler/src/agent/from_request_params.rs @@ -1,6 +1,9 @@ +use std::sync::Arc; + use tokio::sync::mpsc; use crate::agent::jsonrpc::response::Response; +use crate::slot_aggregated_status::SlotAggregatedStatus; pub trait FromRequestParams: Send + Sync { type RequestParams; @@ -10,5 +13,6 @@ pub trait FromRequestParams: Send + Sync { params: Self::RequestParams, response_tx: mpsc::UnboundedSender, stop_rx: mpsc::UnboundedReceiver<()>, + slot_aggregated_status: Arc, ) -> Self; } diff --git a/paddler/src/agent/generate_embedding_batch_request.rs b/paddler/src/agent/generate_embedding_batch_request.rs index 112b6228..f7139022 100644 --- a/paddler/src/agent/generate_embedding_batch_request.rs +++ b/paddler/src/agent/generate_embedding_batch_request.rs @@ -1,13 +1,18 @@ +use std::sync::Arc; + use paddler_types::embedding_result::EmbeddingResult; use paddler_types::request_params::GenerateEmbeddingBatchParams; use tokio::sync::mpsc; use crate::agent::from_request_params::FromRequestParams; +use crate::agent::slot_guard::SlotGuard; +use crate::slot_aggregated_status::SlotAggregatedStatus; pub struct GenerateEmbeddingBatchRequest { pub generate_embedding_stop_rx: mpsc::UnboundedReceiver<()>, pub generated_embedding_tx: mpsc::UnboundedSender, pub params: GenerateEmbeddingBatchParams, + pub slot_guard: SlotGuard, } impl FromRequestParams for GenerateEmbeddingBatchRequest { @@ -18,11 +23,13 @@ impl FromRequestParams for GenerateEmbeddingBatchRequest { params: Self::RequestParams, generated_embedding_tx: mpsc::UnboundedSender, generate_embedding_stop_rx: mpsc::UnboundedReceiver<()>, + slot_aggregated_status: Arc, ) -> Self { Self { generate_embedding_stop_rx, generated_embedding_tx, params, + slot_guard: SlotGuard::new(slot_aggregated_status), } } } diff --git a/paddler/src/agent/llamacpp_arbiter_service.rs b/paddler/src/agent/llamacpp_arbiter_service.rs index b3cf27df..8d60fb56 100644 --- a/paddler/src/agent/llamacpp_arbiter_service.rs +++ b/paddler/src/agent/llamacpp_arbiter_service.rs @@ -2,15 +2,11 @@ use std::sync::Arc; use anyhow::Context as _; use anyhow::Result; -use anyhow::anyhow; use async_trait::async_trait; use log::error; use log::info; use log::warn; -use paddler_types::agent_issue::AgentIssue; -use paddler_types::agent_issue_params::ModelPath; use paddler_types::agent_state_application_status::AgentStateApplicationStatus; -use tokio::fs; use tokio::sync::mpsc; use tokio::time::Duration; use tokio::time::MissedTickBehavior; @@ -20,6 +16,7 @@ use tokio_util::sync::CancellationToken; use crate::agent::continue_from_conversation_history_request::ContinueFromConversationHistoryRequest; use crate::agent::continue_from_raw_prompt_request::ContinueFromRawPromptRequest; use crate::agent::continuous_batch_arbiter::ContinuousBatchArbiter; +use crate::agent::continuous_batch_arbiter_build_outcome::ContinuousBatchArbiterBuildOutcome; use crate::agent::continuous_batch_arbiter_handle::ContinuousBatchArbiterHandle; use crate::agent::continuous_batch_scheduler_command::ContinuousBatchSchedulerCommand; use crate::agent::drain_in_flight_requests::drain_in_flight_requests; @@ -27,7 +24,6 @@ use crate::agent::generate_embedding_batch_request::GenerateEmbeddingBatchReques use crate::agent::model_metadata_holder::ModelMetadataHolder; use crate::agent_applicable_state::AgentApplicableState; use crate::agent_applicable_state_holder::AgentApplicableStateHolder; -use crate::agent_issue_fix::AgentIssueFix; use crate::service::Service; use crate::slot_aggregated_status_manager::SlotAggregatedStatusManager; @@ -47,127 +43,29 @@ pub struct LlamaCppArbiterService { impl LlamaCppArbiterService { async fn apply_state(&mut self, shutdown: &CancellationToken) -> Result<()> { - if self.continuous_batch_arbiter_handle.is_some() { - drain_in_flight_requests(&self.slot_aggregated_status_manager, shutdown).await?; - } - - if let Some(arbiter_handle) = self.continuous_batch_arbiter_handle.take() { - arbiter_handle - .shutdown() - .context("Unable to stop arbiter controller")?; - } + self.wait_for_in_flight_requests_to_finish(shutdown).await?; + self.tear_down_arbiter()?; - if let Some(AgentApplicableState { - chat_template_override, - inference_parameters, - multimodal_projection_path, - model_path, - }) = self.agent_applicable_state.clone() - { + if let Some(applicable_state) = self.agent_applicable_state.clone() { self.slot_aggregated_status_manager.reset(); - if let Some(model_path) = model_path { - if !fs::try_exists(&model_path).await? { - self.slot_aggregated_status_manager - .slot_aggregated_status - .register_issue(AgentIssue::ModelFileDoesNotExist(ModelPath { - model_path: model_path.display().to_string(), - })); - - return Err(anyhow!( - "Model path does not exist: {}", - model_path.display() - )); + match ContinuousBatchArbiter::build_from_applicable_state( + applicable_state, + self.agent_name.clone(), + self.desired_slots_total, + self.model_metadata_holder.clone(), + self.slot_aggregated_status_manager.clone(), + ) { + ContinuousBatchArbiterBuildOutcome::ReadyToSpawn(arbiter) => { + self.continuous_batch_arbiter_handle = Some(arbiter.spawn().await?); + info!("Reconciled state change applied successfully"); } - - let model_path_string = model_path.display().to_string(); - - if self - .slot_aggregated_status_manager - .slot_aggregated_status - .has_issue(&AgentIssue::UnableToFindChatTemplate(ModelPath { - model_path: model_path_string.clone(), - })) - { - self.slot_aggregated_status_manager - .slot_aggregated_status - .set_state_application_status( - AgentStateApplicationStatus::AttemptedAndNotAppliable, - ); - - return Err(anyhow!( - "Unable to establish chat template for model at path: {model_path_string}" - )); - } - - if self - .slot_aggregated_status_manager - .slot_aggregated_status - .has_issue_like(|issue| { - matches!(issue, AgentIssue::ChatTemplateDoesNotCompile(_)) - }) - { - self.slot_aggregated_status_manager - .slot_aggregated_status - .set_state_application_status( - AgentStateApplicationStatus::AttemptedAndNotAppliable, - ); - - return Err(anyhow!( - "Chat template does not compile for model at path: {model_path_string}" - )); - } - - if self - .slot_aggregated_status_manager - .slot_aggregated_status - .has_issue_like(|issue| { - matches!(issue, AgentIssue::MultimodalProjectionCannotBeLoaded(_)) - }) - { - self.slot_aggregated_status_manager - .slot_aggregated_status - .set_state_application_status( - AgentStateApplicationStatus::AttemptedAndNotAppliable, - ); - - return Err(anyhow!( - "Multimodal projection cannot be loaded: {}", - multimodal_projection_path.map_or_else( - || "*cannot establish path*".to_owned(), - |multimodal_projection_path| multimodal_projection_path - .display() - .to_string() - ) - )); + ContinuousBatchArbiterBuildOutcome::NoModelConfigured => { + warn!( + "No model configured in applicable state; skipping llama.cpp initialization" + ); } - - self.slot_aggregated_status_manager - .slot_aggregated_status - .register_fix(&AgentIssueFix::ModelFileExists(ModelPath { - model_path: model_path_string.clone(), - })); - - self.continuous_batch_arbiter_handle = Some( - ContinuousBatchArbiter { - agent_name: self.agent_name.clone(), - chat_template_override, - desired_slots_total: self.desired_slots_total, - inference_parameters, - multimodal_projection_path, - model_metadata_holder: self.model_metadata_holder.clone(), - model_path, - model_path_string, - slot_aggregated_status_manager: self.slot_aggregated_status_manager.clone(), - } - .spawn() - .await?, - ); - } else { - warn!("Model path is not set, skipping llama.cpp initialization"); } - - info!("Reconciled state change applied successfully"); } self.slot_aggregated_status_manager @@ -177,6 +75,27 @@ impl LlamaCppArbiterService { Ok(()) } + async fn wait_for_in_flight_requests_to_finish( + &self, + shutdown: &CancellationToken, + ) -> Result<()> { + if self.continuous_batch_arbiter_handle.is_some() { + drain_in_flight_requests(&self.slot_aggregated_status_manager, shutdown).await?; + } + + Ok(()) + } + + fn tear_down_arbiter(&mut self) -> Result<()> { + if let Some(arbiter_handle) = self.continuous_batch_arbiter_handle.take() { + arbiter_handle + .shutdown() + .context("Unable to stop arbiter controller")?; + } + + Ok(()) + } + fn forward_command(&self, command: ContinuousBatchSchedulerCommand) { if let Some(arbiter_handle) = &self.continuous_batch_arbiter_handle { if let Err(err) = arbiter_handle.command_tx.send(command) { diff --git a/paddler/src/agent/management_socket_client_service.rs b/paddler/src/agent/management_socket_client_service.rs index 9f32e0bb..e7c5bc06 100644 --- a/paddler/src/agent/management_socket_client_service.rs +++ b/paddler/src/agent/management_socket_client_service.rs @@ -57,6 +57,7 @@ struct IncomingMessageContext { model_metadata_holder: Arc, receive_stream_stopper_collection: Arc, message_tx: mpsc::UnboundedSender, + slot_aggregated_status: Arc, } pub struct ManagementSocketClientService { @@ -81,6 +82,7 @@ impl ManagementSocketClientService { request_params: TRequest::RequestParams, receive_stream_stopper_collection: Arc, request_tx: mpsc::UnboundedSender, + slot_aggregated_status: Arc, ) -> Result<()> { let (response_tx, mut response_rx) = mpsc::unbounded_channel::(); let (stop_tx, stop_rx) = mpsc::unbounded_channel::<()>(); @@ -93,6 +95,7 @@ impl ManagementSocketClientService { request_params, response_tx, stop_rx, + slot_aggregated_status, ))?; loop { @@ -130,6 +133,7 @@ impl ManagementSocketClientService { message_tx, model_metadata_holder, receive_stream_stopper_collection, + slot_aggregated_status, }: IncomingMessageContext, deserialized_message: JsonRpcMessage, ) -> Result<()> { @@ -185,6 +189,7 @@ impl ManagementSocketClientService { continue_from_conversation_history_params, receive_stream_stopper_collection, continue_from_conversation_history_request_tx, + slot_aggregated_status, ) .await } @@ -199,6 +204,7 @@ impl ManagementSocketClientService { generate_tokens_params, receive_stream_stopper_collection, continue_from_raw_prompt_request_tx, + slot_aggregated_status, ) .await } @@ -213,6 +219,7 @@ impl ManagementSocketClientService { generate_embedding_batch_params, receive_stream_stopper_collection, generate_embedding_batch_request_tx, + slot_aggregated_status, ) .await } @@ -445,6 +452,7 @@ impl ManagementSocketClientService { model_metadata_holder: self.model_metadata_holder.clone(), receive_stream_stopper_collection: self.receive_stream_stopper_collection.clone(), message_tx: message_tx.clone(), + slot_aggregated_status: self.slot_aggregated_status.clone(), }, msg, &pong_tx, diff --git a/paddler/src/agent/mod.rs b/paddler/src/agent/mod.rs index 0f5a7789..44bcaa90 100644 --- a/paddler/src/agent/mod.rs +++ b/paddler/src/agent/mod.rs @@ -2,6 +2,7 @@ pub mod continue_from_conversation_history_request; pub mod continue_from_raw_prompt_request; pub mod continuous_batch_active_request; pub mod continuous_batch_arbiter; +pub mod continuous_batch_arbiter_build_outcome; pub mod continuous_batch_arbiter_handle; pub mod continuous_batch_embedding_processor; pub mod continuous_batch_request_phase; @@ -28,3 +29,4 @@ pub mod resolved_grammar; pub mod sample_token_at_batch_index; pub mod sampling_outcome; pub mod sequence_id_pool; +pub mod slot_guard; diff --git a/paddler/src/agent/reconciliation_service.rs b/paddler/src/agent/reconciliation_service.rs index 8ca79946..e4a14574 100644 --- a/paddler/src/agent/reconciliation_service.rs +++ b/paddler/src/agent/reconciliation_service.rs @@ -28,11 +28,11 @@ impl ReconciliationService { pub async fn convert_to_applicable_state(&mut self) -> Result<()> { let applicable_state = match &self.agent_desired_state { None => None, - Some(agent_desired_state) => { + Some(agent_desired_state) => Some( agent_desired_state .to_applicable_state(self.slot_aggregated_status.clone()) - .await? - } + .await?, + ), }; self.is_converted_to_applicable_state = true; diff --git a/paddler/src/agent/slot_guard.rs b/paddler/src/agent/slot_guard.rs new file mode 100644 index 00000000..2503739a --- /dev/null +++ b/paddler/src/agent/slot_guard.rs @@ -0,0 +1,106 @@ +use std::sync::Arc; + +use crate::dispenses_slots::DispensesSlots as _; +use crate::slot_aggregated_status::SlotAggregatedStatus; + +pub struct SlotGuard { + slot_aggregated_status: Arc, +} + +impl SlotGuard { + #[must_use] + pub fn new(slot_aggregated_status: Arc) -> Self { + slot_aggregated_status.take_slot(); + + Self { + slot_aggregated_status, + } + } +} + +impl Drop for SlotGuard { + fn drop(&mut self) { + self.slot_aggregated_status.release_slot(); + } +} + +#[cfg(test)] +mod tests { + use std::sync::Arc; + use std::time::Duration; + + use anyhow::Result; + use tokio_util::sync::CancellationToken; + + use crate::agent::drain_in_flight_requests::drain_in_flight_requests; + use crate::agent::slot_guard::SlotGuard; + use crate::slot_aggregated_status_manager::SlotAggregatedStatusManager; + + #[tokio::test] + async fn increments_slot_on_construct_and_releases_on_drop() -> Result<()> { + let slot_aggregated_status_manager = Arc::new(SlotAggregatedStatusManager::new(4)); + + assert_eq!( + slot_aggregated_status_manager + .slot_aggregated_status + .slots_processing_count(), + 0 + ); + + { + let _guard = SlotGuard::new( + slot_aggregated_status_manager + .slot_aggregated_status + .clone(), + ); + + assert_eq!( + slot_aggregated_status_manager + .slot_aggregated_status + .slots_processing_count(), + 1 + ); + } + + assert_eq!( + slot_aggregated_status_manager + .slot_aggregated_status + .slots_processing_count(), + 0 + ); + + Ok(()) + } + + #[tokio::test] + async fn drain_in_flight_requests_blocks_until_guard_dropped() -> Result<()> { + let slot_aggregated_status_manager = Arc::new(SlotAggregatedStatusManager::new(4)); + let shutdown = CancellationToken::new(); + + let guard = SlotGuard::new( + slot_aggregated_status_manager + .slot_aggregated_status + .clone(), + ); + + let manager_for_drain = slot_aggregated_status_manager.clone(); + let shutdown_for_drain = shutdown.clone(); + let mut drain_task = tokio::spawn(async move { + drain_in_flight_requests(&manager_for_drain, &shutdown_for_drain).await + }); + + let blocking_window = Duration::from_millis(50); + let timeout_result = tokio::time::timeout(blocking_window, &mut drain_task).await; + assert!( + timeout_result.is_err(), + "drain_in_flight_requests returned while a SlotGuard was still held" + ); + + drop(guard); + + let unblock_window = Duration::from_millis(500); + tokio::time::timeout(unblock_window, drain_task).await???; + + Ok(()) + } +} diff --git a/paddler/src/agent_desired_model.rs b/paddler/src/agent_desired_model.rs deleted file mode 100644 index 5455eeed..00000000 --- a/paddler/src/agent_desired_model.rs +++ /dev/null @@ -1,147 +0,0 @@ -use std::path::PathBuf; -use std::sync::Arc; - -use anyhow::Result; -use anyhow::anyhow; -use async_trait::async_trait; -use hf_hub::Cache; -use hf_hub::Repo; -use hf_hub::RepoType; -use hf_hub::api::tokio::ApiBuilder; -use hf_hub::api::tokio::ApiError; -use log::warn; -use paddler_types::agent_desired_model::AgentDesiredModel; -use paddler_types::agent_issue::AgentIssue; -use paddler_types::agent_issue_params::HuggingFaceDownloadLock; -use paddler_types::agent_issue_params::ModelPath; -use paddler_types::huggingface_model_reference::HuggingFaceModelReference; -use tokio::time::Duration; -use tokio::time::sleep; - -use crate::agent_issue_fix::AgentIssueFix; -use crate::converts_to_applicable_state::ConvertsToApplicableState; -use crate::slot_aggregated_status::SlotAggregatedStatus; -use crate::slot_aggregated_status_download_progress::SlotAggregatedStatusDownloadProgress; - -const LOCK_RETRY_TIMEOUT: Duration = Duration::from_secs(10); - -#[async_trait] -impl ConvertsToApplicableState for AgentDesiredModel { - type ApplicableState = PathBuf; - type Context = Arc; - - async fn to_applicable_state( - &self, - slot_aggregated_status: Self::Context, - ) -> Result> { - Ok(match self { - Self::HuggingFace(HuggingFaceModelReference { - filename, - repo_id, - revision, - }) => { - let model_path = format!("{repo_id}/{revision}/{filename}"); - - if slot_aggregated_status.has_issue(&AgentIssue::HuggingFaceModelDoesNotExist( - ModelPath { - model_path: model_path.clone(), - }, - )) { - return Err(anyhow!( - "Model '{model_path}' does not exist on Hugging Face. Not attempting to download it again." - )); - } - - let hf_cache = Cache::from_env(); - let hf_api = ApiBuilder::from_cache(hf_cache.clone()).build()?; - let hf_repo = hf_api.repo(Repo::with_revision( - repo_id.to_owned(), - RepoType::Model, - revision.to_owned(), - )); - - if let Some(cached_path) = hf_cache - .repo(Repo::new(repo_id.to_owned(), RepoType::Model)) - .get(filename) - { - slot_aggregated_status.reset_download(); - - return Ok(Some(cached_path)); - } - - let weights_filename = match hf_repo - .download_with_progress( - filename, - SlotAggregatedStatusDownloadProgress::new(slot_aggregated_status.clone()), - ) - .await - { - Ok(resolved_filename) => { - slot_aggregated_status.register_fix( - &AgentIssueFix::HuggingFaceDownloadedModel(ModelPath { model_path }), - ); - - resolved_filename - } - Err(ApiError::LockAcquisition(lock_path)) => { - slot_aggregated_status.register_issue( - AgentIssue::HuggingFaceCannotAcquireLock(HuggingFaceDownloadLock { - lock_path: lock_path.display().to_string(), - model_path: ModelPath { model_path }, - }), - ); - - warn!( - "Waiting to acquire download lock for '{}'. Sleeping for {} secs", - lock_path.display(), - LOCK_RETRY_TIMEOUT.as_secs() - ); - - sleep(LOCK_RETRY_TIMEOUT).await; - - return Err(anyhow!( - "Failed to acquire download lock '{}'. Is more than one agent running on this machine?", - lock_path.display() - )); - } - Err(ApiError::RequestError(reqwest_error)) => match reqwest_error.status() { - Some(reqwest::StatusCode::NOT_FOUND) => { - slot_aggregated_status.register_issue( - AgentIssue::HuggingFaceModelDoesNotExist(ModelPath { - model_path: model_path.clone(), - }), - ); - - return Err(anyhow!( - "Model '{model_path}' does not exist on Hugging Face." - )); - } - Some( - reqwest::StatusCode::FORBIDDEN | reqwest::StatusCode::UNAUTHORIZED, - ) => { - slot_aggregated_status.register_issue( - AgentIssue::HuggingFacePermissions(ModelPath { - model_path: model_path.clone(), - }), - ); - - return Err(anyhow!( - "You do not have enough permissions to download '{model_path}' from Hugging Face." - )); - } - _ => { - return Err(anyhow!( - "Failed to download model from Hugging Face: {reqwest_error}" - )); - } - }, - Err(err_other) => return Err(err_other.into()), - }; - - Some(weights_filename) - } - Self::LocalToAgent(path) => Some(PathBuf::from(path)), - Self::None => None, - }) - } -} diff --git a/paddler/src/agent_desired_state.rs b/paddler/src/agent_desired_state.rs index 541375ea..5313dd59 100644 --- a/paddler/src/agent_desired_state.rs +++ b/paddler/src/agent_desired_state.rs @@ -1,13 +1,43 @@ +use std::path::PathBuf; use std::sync::Arc; use anyhow::Result; +use anyhow::anyhow; use async_trait::async_trait; +use paddler_types::agent_desired_model::AgentDesiredModel; use paddler_types::agent_desired_state::AgentDesiredState; +use paddler_types::agent_issue::AgentIssue; +use paddler_types::agent_issue_params::ModelPath; use crate::agent_applicable_state::AgentApplicableState; use crate::converts_to_applicable_state::ConvertsToApplicableState; +use crate::desired_model_resolution::DesiredModelResolution; +use crate::resolve_desired_model::resolve_desired_model; use crate::slot_aggregated_status::SlotAggregatedStatus; +async fn resolve_into_optional_path( + desired: &AgentDesiredModel, + slot_aggregated_status: &Arc, + on_local_missing: TLocalMissingIssue, +) -> Result> +where + TLocalMissingIssue: FnOnce(ModelPath) -> AgentIssue, +{ + match resolve_desired_model(desired, slot_aggregated_status.clone()).await? { + DesiredModelResolution::NotConfigured => Ok(None), + DesiredModelResolution::Resolved(path) => Ok(Some(path)), + DesiredModelResolution::LocalFileMissing(path) => { + let model_path_string = path.display().to_string(); + + slot_aggregated_status.register_issue(on_local_missing(ModelPath { + model_path: model_path_string.clone(), + })); + + Err(anyhow!("Local file does not exist: {model_path_string}")) + } + } +} + #[async_trait] impl ConvertsToApplicableState for AgentDesiredState { type ApplicableState = AgentApplicableState; @@ -16,21 +46,129 @@ impl ConvertsToApplicableState for AgentDesiredState { async fn to_applicable_state( &self, slot_aggregated_status: Self::Context, - ) -> Result> { - let model_path = self - .model - .to_applicable_state(slot_aggregated_status.clone()) - .await?; - let multimodal_projection_path = self - .multimodal_projection - .to_applicable_state(slot_aggregated_status) - .await?; - - Ok(Some(AgentApplicableState { + ) -> Result { + let model_path = resolve_into_optional_path( + &self.model, + &slot_aggregated_status, + AgentIssue::ModelFileDoesNotExist, + ) + .await?; + + let multimodal_projection_path = resolve_into_optional_path( + &self.multimodal_projection, + &slot_aggregated_status, + AgentIssue::MultimodalProjectionCannotBeLoaded, + ) + .await?; + + Ok(AgentApplicableState { chat_template_override: self.chat_template_override.clone(), inference_parameters: self.inference_parameters.clone(), model_path, multimodal_projection_path, - })) + }) + } +} + +#[cfg(test)] +mod tests { + use std::path::PathBuf; + use std::sync::Arc; + + use anyhow::Result; + use paddler_types::agent_desired_model::AgentDesiredModel; + use paddler_types::agent_desired_state::AgentDesiredState; + use paddler_types::agent_issue::AgentIssue; + use paddler_types::agent_issue_params::ModelPath; + use paddler_types::inference_parameters::InferenceParameters; + use tempfile::TempDir; + + use crate::converts_to_applicable_state::ConvertsToApplicableState; + use crate::slot_aggregated_status::SlotAggregatedStatus; + + fn fresh_status() -> Arc { + Arc::new(SlotAggregatedStatus::new(1)) + } + + fn nonexistent_path_in_temp_dir(label: &str) -> Result<(TempDir, PathBuf)> { + let dir = tempfile::tempdir()?; + let path = dir.path().join(format!("missing-{label}.gguf")); + + Ok((dir, path)) + } + + fn desired_state( + model: AgentDesiredModel, + multimodal_projection: AgentDesiredModel, + ) -> AgentDesiredState { + AgentDesiredState { + chat_template_override: None, + inference_parameters: InferenceParameters::default(), + model, + multimodal_projection, + } + } + + #[tokio::test] + async fn local_missing_model_registers_model_file_does_not_exist_and_errs() -> Result<()> { + let status = fresh_status(); + let (_dir_guard, missing_path) = nonexistent_path_in_temp_dir("model")?; + let desired = desired_state( + AgentDesiredModel::LocalToAgent(missing_path.display().to_string()), + AgentDesiredModel::None, + ); + + let outcome = desired.to_applicable_state(status.clone()).await; + + assert!( + outcome.is_err(), + "AgentDesiredState::to_applicable_state must Err when the model's local path is missing" + ); + assert!( + status.has_issue(&AgentIssue::ModelFileDoesNotExist(ModelPath { + model_path: missing_path.display().to_string(), + })), + "ModelFileDoesNotExist must be registered for a missing local model file" + ); + assert!( + !status.has_issue(&AgentIssue::MultimodalProjectionCannotBeLoaded(ModelPath { + model_path: missing_path.display().to_string(), + })), + "MultimodalProjectionCannotBeLoaded must NOT be registered for a missing model" + ); + + Ok(()) + } + + #[tokio::test] + async fn local_missing_multimodal_projection_registers_multimodal_projection_cannot_be_loaded_and_errs() + -> Result<()> { + let status = fresh_status(); + let (_dir_guard, missing_path) = nonexistent_path_in_temp_dir("projection")?; + let desired = desired_state( + AgentDesiredModel::None, + AgentDesiredModel::LocalToAgent(missing_path.display().to_string()), + ); + + let outcome = desired.to_applicable_state(status.clone()).await; + + assert!( + outcome.is_err(), + "AgentDesiredState::to_applicable_state must Err when the projection's local path is missing" + ); + assert!( + status.has_issue(&AgentIssue::MultimodalProjectionCannotBeLoaded(ModelPath { + model_path: missing_path.display().to_string(), + })), + "MultimodalProjectionCannotBeLoaded must be registered for a missing local projection file" + ); + assert!( + !status.has_issue(&AgentIssue::ModelFileDoesNotExist(ModelPath { + model_path: missing_path.display().to_string(), + })), + "ModelFileDoesNotExist must NOT be registered for a missing projection" + ); + + Ok(()) } } diff --git a/paddler/src/balancer/buffered_request_manager.rs b/paddler/src/balancer/buffered_request_manager.rs index 13c9d74b..b6429916 100644 --- a/paddler/src/balancer/buffered_request_manager.rs +++ b/paddler/src/balancer/buffered_request_manager.rs @@ -138,8 +138,7 @@ mod tests { } #[tokio::test(flavor = "current_thread")] - async fn waiter_returns_found_after_agent_registration_with_no_initial_agents() -> Result<()> - { + async fn waiter_returns_found_after_agent_registration_with_no_initial_agents() -> Result<()> { let pool = Arc::new(AgentControllerPool::default()); let manager = Arc::new(BufferedRequestManager::new( pool.clone(), diff --git a/paddler/src/balancer/reconciliation_service.rs b/paddler/src/balancer/reconciliation_service.rs index e6fab0a5..11f95b03 100644 --- a/paddler/src/balancer/reconciliation_service.rs +++ b/paddler/src/balancer/reconciliation_service.rs @@ -26,15 +26,13 @@ pub struct ReconciliationService { impl ReconciliationService { pub async fn convert_to_applicable_state(&mut self) -> Result<()> { - if let Some(balancer_applicable_state) = - self.balancer_desired_state.to_applicable_state(()).await? - { - self.agent_controller_pool - .set_desired_state(balancer_applicable_state.agent_desired_state.clone()) - .await?; - self.balancer_applicable_state_holder - .set_balancer_applicable_state(Some(balancer_applicable_state)); - } + let balancer_applicable_state = self.balancer_desired_state.to_applicable_state(()).await?; + + self.agent_controller_pool + .set_desired_state(balancer_applicable_state.agent_desired_state.clone()) + .await?; + self.balancer_applicable_state_holder + .set_balancer_applicable_state(Some(balancer_applicable_state)); self.is_converted_to_applicable_state = true; diff --git a/paddler/src/balancer_desired_state.rs b/paddler/src/balancer_desired_state.rs index ad810933..d714027e 100644 --- a/paddler/src/balancer_desired_state.rs +++ b/paddler/src/balancer_desired_state.rs @@ -10,12 +10,9 @@ impl ConvertsToApplicableState for BalancerDesiredState { type ApplicableState = BalancerApplicableState; type Context = (); - async fn to_applicable_state( - &self, - _context: Self::Context, - ) -> Result> { - Ok(Some(BalancerApplicableState { + async fn to_applicable_state(&self, _context: Self::Context) -> Result { + Ok(BalancerApplicableState { agent_desired_state: self.to_agent_desired_state(), - })) + }) } } diff --git a/paddler/src/chat_template_renderer/pyjinja_tojson.rs b/paddler/src/chat_template_renderer/pyjinja_tojson.rs index 71e61276..cfcc8db6 100644 --- a/paddler/src/chat_template_renderer/pyjinja_tojson.rs +++ b/paddler/src/chat_template_renderer/pyjinja_tojson.rs @@ -4,20 +4,6 @@ use minijinja::Value; use minijinja::filters::tojson; use minijinja::value::Kwargs; -// Python-style `tojson` filter compatible with HuggingFace transformers chat -// templates that pass Jinja2 kwargs (`ensure_ascii`, `sort_keys`, `separators`, -// `indent`). minijinja's built-in `tojson` only accepts `indent`, so any of the -// others crashes rendering with "too many arguments". This wrapper: -// -// 1. Recognises every Python `tojson` kwarg explicitly (whitelist). -// 2. Accepts only values whose semantics match minijinja's defaults -// (`ensure_ascii=False`, `sort_keys=False`); rejects anything else with a -// clear error so the template author knows to remove it. -// 3. Forwards `indent` plus an empty kwargs map to minijinja's built-in -// `tojson` for the actual JSON serialisation, so behaviour and output -// formatting stay identical. -// 4. Calls `Kwargs::assert_all_used` so unknown kwargs (anything not in our -// whitelist) hard-error rather than getting silently dropped. #[expect( clippy::needless_pass_by_value, reason = "minijinja's Filter trait requires Kwargs by value; taking &Kwargs makes the \ @@ -200,10 +186,8 @@ mod tests { #[test] fn unknown_kwarg_returns_error() -> Result<()> { - let err = render_expecting_error( - "{{ value | tojson(bogus=42) }}", - context! { value => "x" }, - )?; + let err = + render_expecting_error("{{ value | tojson(bogus=42) }}", context! { value => "x" })?; let rendered = err.to_string(); if !rendered.contains("bogus") { diff --git a/paddler/src/converts_to_applicable_state.rs b/paddler/src/converts_to_applicable_state.rs index 210176bf..4e8a065d 100644 --- a/paddler/src/converts_to_applicable_state.rs +++ b/paddler/src/converts_to_applicable_state.rs @@ -6,8 +6,5 @@ pub trait ConvertsToApplicableState { type ApplicableState; type Context; - async fn to_applicable_state( - &self, - context: Self::Context, - ) -> Result>; + async fn to_applicable_state(&self, context: Self::Context) -> Result; } diff --git a/paddler/src/desired_model_resolution.rs b/paddler/src/desired_model_resolution.rs new file mode 100644 index 00000000..a825b19d --- /dev/null +++ b/paddler/src/desired_model_resolution.rs @@ -0,0 +1,7 @@ +use std::path::PathBuf; + +pub enum DesiredModelResolution { + NotConfigured, + Resolved(PathBuf), + LocalFileMissing(PathBuf), +} diff --git a/paddler/src/download_huggingface_model.rs b/paddler/src/download_huggingface_model.rs new file mode 100644 index 00000000..787d8d40 --- /dev/null +++ b/paddler/src/download_huggingface_model.rs @@ -0,0 +1,125 @@ +use std::path::PathBuf; +use std::sync::Arc; + +use anyhow::Result; +use anyhow::anyhow; +use hf_hub::Cache; +use hf_hub::Repo; +use hf_hub::RepoType; +use hf_hub::api::tokio::ApiBuilder; +use hf_hub::api::tokio::ApiError; +use log::warn; +use paddler_types::agent_issue::AgentIssue; +use paddler_types::agent_issue_params::HuggingFaceDownloadLock; +use paddler_types::agent_issue_params::ModelPath; +use paddler_types::huggingface_model_reference::HuggingFaceModelReference; +use tokio::time::Duration; +use tokio::time::sleep; + +use crate::agent_issue_fix::AgentIssueFix; +use crate::slot_aggregated_status::SlotAggregatedStatus; +use crate::slot_aggregated_status_download_progress::SlotAggregatedStatusDownloadProgress; + +const LOCK_RETRY_TIMEOUT: Duration = Duration::from_secs(10); + +pub async fn download_huggingface_model( + reference: &HuggingFaceModelReference, + slot_aggregated_status: Arc, +) -> Result { + let HuggingFaceModelReference { + filename, + repo_id, + revision, + } = reference; + let model_path = format!("{repo_id}/{revision}/{filename}"); + + if slot_aggregated_status.has_issue(&AgentIssue::HuggingFaceModelDoesNotExist(ModelPath { + model_path: model_path.clone(), + })) { + return Err(anyhow!( + "Model '{model_path}' does not exist on Hugging Face. Not attempting to download it again." + )); + } + + let hf_cache = Cache::from_env(); + let hf_api = ApiBuilder::from_cache(hf_cache.clone()).build()?; + let hf_repo = hf_api.repo(Repo::with_revision( + repo_id.to_owned(), + RepoType::Model, + revision.to_owned(), + )); + + if let Some(cached_path) = hf_cache + .repo(Repo::new(repo_id.to_owned(), RepoType::Model)) + .get(filename) + { + slot_aggregated_status.reset_download(); + + return Ok(cached_path); + } + + match hf_repo + .download_with_progress( + filename, + SlotAggregatedStatusDownloadProgress::new(slot_aggregated_status.clone()), + ) + .await + { + Ok(resolved_filename) => { + slot_aggregated_status.register_fix(&AgentIssueFix::HuggingFaceDownloadedModel( + ModelPath { model_path }, + )); + + Ok(resolved_filename) + } + Err(ApiError::LockAcquisition(lock_path)) => { + slot_aggregated_status.register_issue(AgentIssue::HuggingFaceCannotAcquireLock( + HuggingFaceDownloadLock { + lock_path: lock_path.display().to_string(), + model_path: ModelPath { model_path }, + }, + )); + + warn!( + "Waiting to acquire download lock for '{}'. Sleeping for {} secs", + lock_path.display(), + LOCK_RETRY_TIMEOUT.as_secs() + ); + + sleep(LOCK_RETRY_TIMEOUT).await; + + Err(anyhow!( + "Failed to acquire download lock '{}'. Is more than one agent running on this machine?", + lock_path.display() + )) + } + Err(ApiError::RequestError(reqwest_error)) => match reqwest_error.status() { + Some(reqwest::StatusCode::NOT_FOUND) => { + slot_aggregated_status.register_issue(AgentIssue::HuggingFaceModelDoesNotExist( + ModelPath { + model_path: model_path.clone(), + }, + )); + + Err(anyhow!( + "Model '{model_path}' does not exist on Hugging Face." + )) + } + Some(reqwest::StatusCode::FORBIDDEN | reqwest::StatusCode::UNAUTHORIZED) => { + slot_aggregated_status.register_issue(AgentIssue::HuggingFacePermissions( + ModelPath { + model_path: model_path.clone(), + }, + )); + + Err(anyhow!( + "You do not have enough permissions to download '{model_path}' from Hugging Face." + )) + } + _ => Err(anyhow!( + "Failed to download model from Hugging Face: {reqwest_error}" + )), + }, + Err(err_other) => Err(err_other.into()), + } +} diff --git a/paddler/src/lib.rs b/paddler/src/lib.rs index 99ce3c1f..88a18d87 100644 --- a/paddler/src/lib.rs +++ b/paddler/src/lib.rs @@ -1,7 +1,6 @@ pub mod agent; pub mod agent_applicable_state; pub mod agent_applicable_state_holder; -pub mod agent_desired_model; pub mod agent_desired_state; pub mod agent_issue_fix; pub mod atomic_value; @@ -21,9 +20,12 @@ pub mod converts_to_llama_pooling_type; pub mod create_cors_middleware; pub mod decoded_image; pub mod decoded_image_error; +pub mod desired_model_resolution; pub mod dispenses_slots; +pub mod download_huggingface_model; pub mod embedding_input_tokenized; pub mod produces_snapshot; +pub mod resolve_desired_model; pub mod resolved_socket_addr; pub mod sends_rpc_message; pub mod service; diff --git a/paddler/src/resolve_desired_model.rs b/paddler/src/resolve_desired_model.rs new file mode 100644 index 00000000..25683ea8 --- /dev/null +++ b/paddler/src/resolve_desired_model.rs @@ -0,0 +1,103 @@ +use std::path::PathBuf; +use std::sync::Arc; + +use anyhow::Result; +use paddler_types::agent_desired_model::AgentDesiredModel; + +use crate::desired_model_resolution::DesiredModelResolution; +use crate::download_huggingface_model::download_huggingface_model; +use crate::slot_aggregated_status::SlotAggregatedStatus; + +pub async fn resolve_desired_model( + desired: &AgentDesiredModel, + slot_aggregated_status: Arc, +) -> Result { + match desired { + AgentDesiredModel::HuggingFace(reference) => { + let path = download_huggingface_model(reference, slot_aggregated_status).await?; + + Ok(DesiredModelResolution::Resolved(path)) + } + AgentDesiredModel::LocalToAgent(path) => { + let local_path = PathBuf::from(path); + + if tokio::fs::try_exists(&local_path).await? { + Ok(DesiredModelResolution::Resolved(local_path)) + } else { + Ok(DesiredModelResolution::LocalFileMissing(local_path)) + } + } + AgentDesiredModel::None => Ok(DesiredModelResolution::NotConfigured), + } +} + +#[cfg(test)] +mod tests { + use std::path::PathBuf; + use std::sync::Arc; + + use anyhow::Result; + use paddler_types::agent_desired_model::AgentDesiredModel; + use tempfile::NamedTempFile; + use tempfile::TempDir; + + use crate::desired_model_resolution::DesiredModelResolution; + use crate::resolve_desired_model::resolve_desired_model; + use crate::slot_aggregated_status::SlotAggregatedStatus; + + fn fresh_status() -> Arc { + Arc::new(SlotAggregatedStatus::new(1)) + } + + fn nonexistent_path_in_temp_dir(label: &str) -> Result<(TempDir, PathBuf)> { + let dir = tempfile::tempdir()?; + let path = dir.path().join(format!("missing-{label}.gguf")); + + Ok((dir, path)) + } + + #[tokio::test] + async fn local_existing_file_resolves_to_resolved_with_that_path() -> Result<()> { + let status = fresh_status(); + let temp_file = NamedTempFile::new()?; + let path = temp_file.path().to_path_buf(); + let desired = AgentDesiredModel::LocalToAgent(path.display().to_string()); + + let resolution = resolve_desired_model(&desired, status).await?; + + assert!(matches!( + resolution, + DesiredModelResolution::Resolved(ref resolved) if *resolved == path + )); + + Ok(()) + } + + #[tokio::test] + async fn local_missing_file_resolves_to_local_file_missing_with_that_path() -> Result<()> { + let status = fresh_status(); + let (_dir_guard, path) = nonexistent_path_in_temp_dir("desired")?; + let desired = AgentDesiredModel::LocalToAgent(path.display().to_string()); + + let resolution = resolve_desired_model(&desired, status).await?; + + assert!(matches!( + resolution, + DesiredModelResolution::LocalFileMissing(ref missing) if *missing == path + )); + + Ok(()) + } + + #[tokio::test] + async fn none_variant_resolves_to_not_configured() -> Result<()> { + let status = fresh_status(); + let desired = AgentDesiredModel::None; + + let resolution = resolve_desired_model(&desired, status).await?; + + assert!(matches!(resolution, DesiredModelResolution::NotConfigured)); + + Ok(()) + } +} diff --git a/paddler/src/tool_call_pipeline.rs b/paddler/src/tool_call_pipeline.rs index 5f9cb4c0..6499b110 100644 --- a/paddler/src/tool_call_pipeline.rs +++ b/paddler/src/tool_call_pipeline.rs @@ -42,7 +42,10 @@ impl ToolCallPipeline { return ToolCallEvent::Resolved(Vec::new()); } - match self.model.parse_chat_message(&self.tools_json, &input, false) { + match self + .model + .parse_chat_message(&self.tools_json, &input, false) + { Ok(parsed) => self.validate_resolved(parsed.tool_calls), Err(err) => ToolCallEvent::ParseFailed(ToolCallPipelineError::Bindings(err)), } From b111348bbc2e9d18dfc266eb46afc6d55c87b41a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ma=C5=82gorzata=20Zagajewska?= Date: Sat, 9 May 2026 19:12:30 +0200 Subject: [PATCH 27/51] highlight tokens with different colors, reorganize paddler_client_cli --- package-lock.json | 15 + paddler_client_cli/src/chat_session.rs | 26 +- paddler_client_cli/src/cmd/mod.rs | 3 - paddler_client_cli/src/cmd/prompt.rs | 12 +- .../src/cmd/value_parser/mod.rs | 1 - paddler_client_cli/src/main.rs | 13 +- paddler_client_cli/src/panel_navigation.rs | 180 ------------ .../{cmd/load_tool.rs => prompt_load_tool.rs} | 2 +- ...e_url.rs => prompt_parse_inference_url.rs} | 2 +- ...inking_mode.rs => prompt_thinking_mode.rs} | 4 +- paddler_client_cli/src/streaming_response.rs | 40 +-- ...der_chat_panels.rs => view_chat_panels.rs} | 258 +++++++++++++----- .../src/{panel_kind.rs => view_panel_kind.rs} | 4 +- ...t_panel_layout.rs => view_panel_layout.rs} | 28 +- .../src/view_panel_navigation.rs | 185 +++++++++++++ ...rminal_guard.rs => view_terminal_guard.rs} | 6 +- 16 files changed, 462 insertions(+), 317 deletions(-) delete mode 100644 paddler_client_cli/src/cmd/value_parser/mod.rs delete mode 100644 paddler_client_cli/src/panel_navigation.rs rename paddler_client_cli/src/{cmd/load_tool.rs => prompt_load_tool.rs} (91%) rename paddler_client_cli/src/{cmd/value_parser/parse_inference_url.rs => prompt_parse_inference_url.rs} (64%) rename paddler_client_cli/src/{cmd/thinking_mode.rs => prompt_thinking_mode.rs} (75%) rename paddler_client_cli/src/{render_chat_panels.rs => view_chat_panels.rs} (51%) rename paddler_client_cli/src/{panel_kind.rs => view_panel_kind.rs} (89%) rename paddler_client_cli/src/{chat_panel_layout.rs => view_panel_layout.rs} (65%) create mode 100644 paddler_client_cli/src/view_panel_navigation.rs rename paddler_client_cli/src/{raw_terminal_guard.rs => view_terminal_guard.rs} (96%) diff --git a/package-lock.json b/package-lock.json index 6b6359e4..3cb90abb 100644 --- a/package-lock.json +++ b/package-lock.json @@ -225,6 +225,7 @@ "resolved": "https://registry.npmjs.org/@codemirror/view/-/view-6.38.1.tgz", "integrity": "sha512-RmTOkE7hRU3OVREqFVITWHz6ocgBjv08GoePscAakgVQfciA3SGCEk7mb9IzwW61cKKmlTpHXG6DUE5Ubx+MGQ==", "license": "MIT", + "peer": true, "dependencies": { "@codemirror/state": "^6.5.0", "crelt": "^1.0.6", @@ -248,6 +249,7 @@ } ], "license": "MIT", + "peer": true, "engines": { "node": ">=18" }, @@ -271,6 +273,7 @@ } ], "license": "MIT", + "peer": true, "engines": { "node": ">=18" } @@ -1313,6 +1316,7 @@ "resolved": "https://registry.npmjs.org/@types/react/-/react-19.1.10.tgz", "integrity": "sha512-EhBeSYX0Y6ye8pNebpKrwFJq7BoQ8J5SO6NlvNwwHjSj6adXJViPQrKlsyPw7hLBLvckEMO1yxeGdR82YBBlDg==", "license": "MIT", + "peer": true, "dependencies": { "csstype": "^3.0.2" } @@ -1379,6 +1383,7 @@ "integrity": "sha512-pUXGCuHnnKw6PyYq93lLRiZm3vjuslIy7tus1lIQTYVK9bL8XBgJnCWm8a0KcTtHC84Yya1Q6rtll+duSMj0dg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.39.1", "@typescript-eslint/types": "8.39.1", @@ -1780,6 +1785,7 @@ "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -2885,6 +2891,7 @@ "integrity": "sha512-TS9bTNIryDzStCpJN93aC5VRSW3uTx9sClUn4B87pwiCaJh220otoI0X8mJKr+VcPtniMdN8GKjlwgWGUv5ZKA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/regexpp": "^4.12.1", @@ -5444,6 +5451,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", @@ -5556,6 +5564,7 @@ "integrity": "sha512-8sLjZwK0R+JlxlYcTuVnyT2v+htpdrjDOKuMcOVdYjt52Lh8hWRYpxBPoKx/Zg+bcjc3wx6fmQevMmUztS/ccA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "cssesc": "^3.0.0", "util-deprecate": "^1.0.2" @@ -5606,6 +5615,7 @@ "integrity": "sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ==", "dev": true, "license": "MIT", + "peer": true, "bin": { "prettier": "bin/prettier.cjs" }, @@ -5695,6 +5705,7 @@ "resolved": "https://registry.npmjs.org/react/-/react-19.1.1.tgz", "integrity": "sha512-w8nqGImo45dmMIfljjMwOGtbmC/mk4CMYhWIicdSflH91J9TyCyczcPFXJzrZ/ZXcgGRFeP6BU0BEJTw6tZdfQ==", "license": "MIT", + "peer": true, "engines": { "node": ">=0.10.0" } @@ -5704,6 +5715,7 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.1.1.tgz", "integrity": "sha512-Dlq/5LAZgF0Gaz6yiqZCf6VCcZs1ghAJyrsu84Q/GT0gV+mCxbfmKNoGRKBYMJ8IEdGPqu49YWXD02GCknEDkw==", "license": "MIT", + "peer": true, "dependencies": { "scheduler": "^0.26.0" }, @@ -5897,6 +5909,7 @@ "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz", "integrity": "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==", "license": "Apache-2.0", + "peer": true, "dependencies": { "tslib": "^2.1.0" } @@ -7198,6 +7211,7 @@ "integrity": "sha512-CWBzXQrc/qOkhidw1OzBTQuYRbfyxDXJMVJ1XNwUHGROVmuaeiEm3OslpZ1RV96d7SKKjZKrSJu3+t/xlw3R9A==", "dev": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -7784,6 +7798,7 @@ "resolved": "https://registry.npmjs.org/zod/-/zod-4.0.17.tgz", "integrity": "sha512-1PHjlYRevNxxdy2JZ8JcNAw7rX8V9P1AKkP+x/xZfxB0K5FYfuV+Ug6P/6NVSR2jHQ+FzDDoDHS04nYUsOIyLQ==", "license": "MIT", + "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } diff --git a/paddler_client_cli/src/chat_session.rs b/paddler_client_cli/src/chat_session.rs index 2ff96c2c..5742499a 100644 --- a/paddler_client_cli/src/chat_session.rs +++ b/paddler_client_cli/src/chat_session.rs @@ -17,12 +17,12 @@ use ratatui::backend::CrosstermBackend; use ratatui::layout::Rect; use tokio_util::sync::CancellationToken; -use crate::chat_panel_layout::ChatPanelLayout; use crate::chat_session_event::ChatSessionEvent; -use crate::panel_navigation::PanelNavigation; -use crate::raw_terminal_guard::RawTerminalGuard; -use crate::render_chat_panels::render_chat_panels; use crate::streaming_response::StreamingResponse; +use crate::view_chat_panels::view_chat_panels; +use crate::view_panel_layout::ViewPanelLayout; +use crate::view_panel_navigation::ViewPanelNavigation; +use crate::view_terminal_guard::ViewTerminalGuard; const MOUSE_WHEEL_LINES: u16 = 3; const ARROW_KEY_LINES: u16 = 1; @@ -30,7 +30,7 @@ const ARROW_KEY_LINES: u16 = 1; pub struct ChatSession { inference_stream: InferenceMessageStream, state: StreamingResponse, - navigation: PanelNavigation, + navigation: ViewPanelNavigation, shutdown: CancellationToken, } @@ -39,19 +39,19 @@ impl ChatSession { Self { inference_stream, state: StreamingResponse::default(), - navigation: PanelNavigation::default(), + navigation: ViewPanelNavigation::default(), shutdown, } } pub async fn run(mut self) -> Result<()> { - let _terminal_guard = RawTerminalGuard::enter()?; + let _terminal_guard = ViewTerminalGuard::enter()?; let mut terminal = Terminal::new(CrosstermBackend::new(io::stdout()))?; let mut events = EventStream::new(); let mut layout = compute_layout(&terminal)?; terminal.draw(|frame| { - render_chat_panels(&self.state, &mut self.navigation, &layout, frame); + view_chat_panels(&self.state, &mut self.navigation, &layout, frame); })?; loop { @@ -83,7 +83,7 @@ impl ChatSession { } layout = compute_layout(&terminal)?; terminal.draw(|frame| { - render_chat_panels(&self.state, &mut self.navigation, &layout, frame); + view_chat_panels(&self.state, &mut self.navigation, &layout, frame); })?; } } @@ -114,7 +114,7 @@ impl ChatSession { } } - fn handle_navigation_key(&mut self, key_event: KeyEvent, layout: &ChatPanelLayout) { + fn handle_navigation_key(&mut self, key_event: KeyEvent, layout: &ViewPanelLayout) { let focused = self.navigation.focused(); let viewport_rows = layout.viewport_rows(focused); let page_lines = viewport_rows.saturating_sub(1).max(1); @@ -131,7 +131,7 @@ impl ChatSession { } } - fn handle_mouse(&mut self, mouse_event: MouseEvent, layout: &ChatPanelLayout) { + fn handle_mouse(&mut self, mouse_event: MouseEvent, layout: &ViewPanelLayout) { let Some(panel) = layout.panel_at(mouse_event.column, mouse_event.row) else { return; }; @@ -152,9 +152,9 @@ impl ChatSession { } } -fn compute_layout(terminal: &Terminal>) -> Result { +fn compute_layout(terminal: &Terminal>) -> Result { let size = terminal.size()?; - Ok(ChatPanelLayout::compute(Rect::new( + Ok(ViewPanelLayout::compute(Rect::new( 0, 0, size.width, diff --git a/paddler_client_cli/src/cmd/mod.rs b/paddler_client_cli/src/cmd/mod.rs index 973fe156..bc27b40c 100644 --- a/paddler_client_cli/src/cmd/mod.rs +++ b/paddler_client_cli/src/cmd/mod.rs @@ -1,5 +1,2 @@ pub mod handler; -pub mod load_tool; pub mod prompt; -pub mod thinking_mode; -pub mod value_parser; diff --git a/paddler_client_cli/src/cmd/prompt.rs b/paddler_client_cli/src/cmd/prompt.rs index 72dcfebf..13efb6ed 100644 --- a/paddler_client_cli/src/cmd/prompt.rs +++ b/paddler_client_cli/src/cmd/prompt.rs @@ -13,14 +13,14 @@ use tokio_util::sync::CancellationToken; use url::Url; use super::handler::Handler; -use super::load_tool::load_tool; -use super::thinking_mode::ThinkingMode; -use super::value_parser::parse_inference_url::parse_inference_url; use crate::chat_session::ChatSession; +use crate::prompt_load_tool::prompt_load_tool; +use crate::prompt_parse_inference_url::prompt_parse_inference_url; +use crate::prompt_thinking_mode::PromptThinkingMode; #[derive(Parser)] pub struct Prompt { - #[arg(long, value_parser = parse_inference_url)] + #[arg(long, value_parser = prompt_parse_inference_url)] /// Address of the inference server (e.g. 127.0.0.1:8061) inference_addr: Url, @@ -30,7 +30,7 @@ pub struct Prompt { #[arg(long, value_enum)] /// Whether chain-of-thought thinking is on or off - thinking: ThinkingMode, + thinking: PromptThinkingMode, #[arg(long, action = clap::ArgAction::Append)] /// Path to a JSON file describing one tool (repeatable) @@ -46,7 +46,7 @@ impl Handler for Prompt { let tools = self .tool .iter() - .map(|path| load_tool(path)) + .map(|path| prompt_load_tool(path)) .collect::>>()?; let request = ContinueFromConversationHistoryParams { diff --git a/paddler_client_cli/src/cmd/value_parser/mod.rs b/paddler_client_cli/src/cmd/value_parser/mod.rs deleted file mode 100644 index 474b0ae5..00000000 --- a/paddler_client_cli/src/cmd/value_parser/mod.rs +++ /dev/null @@ -1 +0,0 @@ -pub mod parse_inference_url; diff --git a/paddler_client_cli/src/main.rs b/paddler_client_cli/src/main.rs index 8ad08da0..ba7aec89 100644 --- a/paddler_client_cli/src/main.rs +++ b/paddler_client_cli/src/main.rs @@ -1,13 +1,16 @@ -mod chat_panel_layout; mod chat_session; mod chat_session_event; mod cmd; -mod panel_kind; -mod panel_navigation; -mod raw_terminal_guard; -mod render_chat_panels; +mod prompt_load_tool; +mod prompt_parse_inference_url; +mod prompt_thinking_mode; mod stop_reason; mod streaming_response; +mod view_chat_panels; +mod view_panel_kind; +mod view_panel_layout; +mod view_panel_navigation; +mod view_terminal_guard; use anyhow::Result; use clap::Parser; diff --git a/paddler_client_cli/src/panel_navigation.rs b/paddler_client_cli/src/panel_navigation.rs deleted file mode 100644 index 086abd19..00000000 --- a/paddler_client_cli/src/panel_navigation.rs +++ /dev/null @@ -1,180 +0,0 @@ -use crate::panel_kind::PanelKind; - -const PANEL_COUNT: usize = 4; - -pub struct PanelNavigation { - focused: PanelKind, - views: [PanelView; PANEL_COUNT], -} - -#[derive(Clone, Copy)] -struct PanelView { - position: usize, - follow_bottom: bool, -} - -impl Default for PanelView { - fn default() -> Self { - Self { - position: 0, - follow_bottom: true, - } - } -} - -impl Default for PanelNavigation { - fn default() -> Self { - Self { - focused: PanelKind::Response, - views: [PanelView::default(); PANEL_COUNT], - } - } -} - -impl PanelNavigation { - pub const fn focused(&self) -> PanelKind { - self.focused - } - - pub const fn focus(&mut self, panel: PanelKind) { - self.focused = panel; - } - - pub const fn cycle_focus_forward(&mut self) { - self.focused = match self.focused { - PanelKind::Thinking => PanelKind::Response, - PanelKind::Response => PanelKind::ToolCalls, - PanelKind::ToolCalls => PanelKind::Undetermined, - PanelKind::Undetermined => PanelKind::Thinking, - }; - } - - pub const fn cycle_focus_backward(&mut self) { - self.focused = match self.focused { - PanelKind::Thinking => PanelKind::Undetermined, - PanelKind::Response => PanelKind::Thinking, - PanelKind::ToolCalls => PanelKind::Response, - PanelKind::Undetermined => PanelKind::ToolCalls, - }; - } - - pub fn scroll_up(&mut self, panel: PanelKind, lines: u16) { - let view = &mut self.views[panel as usize]; - view.follow_bottom = false; - view.position = view.position.saturating_sub(lines.into()); - } - - pub fn scroll_down(&mut self, panel: PanelKind, lines: u16) { - let view = &mut self.views[panel as usize]; - view.position = view.position.saturating_add(lines.into()); - } - - pub const fn jump_to_top(&mut self, panel: PanelKind) { - let view = &mut self.views[panel as usize]; - view.follow_bottom = false; - view.position = 0; - } - - pub const fn jump_to_bottom(&mut self, panel: PanelKind) { - self.views[panel as usize].follow_bottom = true; - } - - pub const fn settle(&mut self, panel: PanelKind, content_rows: usize, viewport_rows: usize) { - let view = &mut self.views[panel as usize]; - let max_position = content_rows.saturating_sub(viewport_rows); - if view.follow_bottom || view.position >= max_position { - view.position = max_position; - view.follow_bottom = true; - } - } - - pub const fn position(&self, panel: PanelKind) -> usize { - self.views[panel as usize].position - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn defaults_focus_response_and_follow_bottom() { - let mut nav = PanelNavigation::default(); - - assert_eq!(nav.focused(), PanelKind::Response); - nav.settle(PanelKind::Response, 100, 10); - assert_eq!(nav.position(PanelKind::Response), 90); - } - - #[test] - fn scroll_up_disengages_follow_and_decrements_position() { - let mut nav = PanelNavigation::default(); - - nav.scroll_up(PanelKind::Response, 5); - - nav.settle(PanelKind::Response, 100, 10); - assert_eq!(nav.position(PanelKind::Response), 0); - } - - #[test] - fn scroll_down_after_scroll_up_advances_within_content() { - let mut nav = PanelNavigation::default(); - nav.scroll_up(PanelKind::Response, 50); - - nav.scroll_down(PanelKind::Response, 10); - - nav.settle(PanelKind::Response, 100, 10); - assert_eq!(nav.position(PanelKind::Response), 10); - } - - #[test] - fn jump_to_bottom_re_engages_auto_follow() { - let mut nav = PanelNavigation::default(); - nav.scroll_up(PanelKind::Response, 50); - - nav.jump_to_bottom(PanelKind::Response); - - nav.settle(PanelKind::Response, 200, 10); - assert_eq!(nav.position(PanelKind::Response), 190); - } - - #[test] - fn cycle_focus_forward_walks_panels_in_reading_order() { - let mut nav = PanelNavigation::default(); - - nav.cycle_focus_forward(); - assert_eq!(nav.focused(), PanelKind::ToolCalls); - nav.cycle_focus_forward(); - assert_eq!(nav.focused(), PanelKind::Undetermined); - nav.cycle_focus_forward(); - assert_eq!(nav.focused(), PanelKind::Thinking); - nav.cycle_focus_forward(); - assert_eq!(nav.focused(), PanelKind::Response); - } - - #[test] - fn scrolling_back_to_bottom_re_engages_auto_follow_for_subsequent_growth() { - let mut nav = PanelNavigation::default(); - nav.settle(PanelKind::Response, 100, 10); - - nav.scroll_up(PanelKind::Response, 5); - nav.settle(PanelKind::Response, 100, 10); - - nav.scroll_down(PanelKind::Response, 10); - nav.settle(PanelKind::Response, 100, 10); - - nav.settle(PanelKind::Response, 110, 10); - - assert_eq!(nav.position(PanelKind::Response), 100); - } - - #[test] - fn position_is_clamped_when_content_shorter_than_stored_offset() { - let mut nav = PanelNavigation::default(); - nav.scroll_up(PanelKind::Response, 0); - nav.scroll_down(PanelKind::Response, 80); - - nav.settle(PanelKind::Response, 30, 10); - assert_eq!(nav.position(PanelKind::Response), 20); - } -} diff --git a/paddler_client_cli/src/cmd/load_tool.rs b/paddler_client_cli/src/prompt_load_tool.rs similarity index 91% rename from paddler_client_cli/src/cmd/load_tool.rs rename to paddler_client_cli/src/prompt_load_tool.rs index e2f59d80..54a20786 100644 --- a/paddler_client_cli/src/cmd/load_tool.rs +++ b/paddler_client_cli/src/prompt_load_tool.rs @@ -8,7 +8,7 @@ use paddler_types::request_params::continue_from_conversation_history_params::to use paddler_types::request_params::continue_from_conversation_history_params::tool::tool_params::function_call::parameters_schema::validated_parameters_schema::ValidatedParametersSchema; use paddler_types::validates::Validates; -pub fn load_tool(path: &Path) -> Result> { +pub fn prompt_load_tool(path: &Path) -> Result> { let file = File::open(path).with_context(|| format!("opening tool file {}", path.display()))?; let raw: Tool = serde_json::from_reader(file) .with_context(|| format!("parsing tool file {}", path.display()))?; diff --git a/paddler_client_cli/src/cmd/value_parser/parse_inference_url.rs b/paddler_client_cli/src/prompt_parse_inference_url.rs similarity index 64% rename from paddler_client_cli/src/cmd/value_parser/parse_inference_url.rs rename to paddler_client_cli/src/prompt_parse_inference_url.rs index b38f60d0..2678ed7c 100644 --- a/paddler_client_cli/src/cmd/value_parser/parse_inference_url.rs +++ b/paddler_client_cli/src/prompt_parse_inference_url.rs @@ -1,6 +1,6 @@ use url::Url; -pub fn parse_inference_url(input_addr: &str) -> Result { +pub fn prompt_parse_inference_url(input_addr: &str) -> Result { Url::parse(&format!("http://{input_addr}")) .map_err(|err| format!("invalid address '{input_addr}': {err}")) } diff --git a/paddler_client_cli/src/cmd/thinking_mode.rs b/paddler_client_cli/src/prompt_thinking_mode.rs similarity index 75% rename from paddler_client_cli/src/cmd/thinking_mode.rs rename to paddler_client_cli/src/prompt_thinking_mode.rs index 179cf24e..573a2a64 100644 --- a/paddler_client_cli/src/cmd/thinking_mode.rs +++ b/paddler_client_cli/src/prompt_thinking_mode.rs @@ -1,12 +1,12 @@ use clap::ValueEnum; #[derive(Clone, Copy, ValueEnum)] -pub enum ThinkingMode { +pub enum PromptThinkingMode { On, Off, } -impl ThinkingMode { +impl PromptThinkingMode { #[must_use] pub const fn is_enabled(self) -> bool { matches!(self, Self::On) diff --git a/paddler_client_cli/src/streaming_response.rs b/paddler_client_cli/src/streaming_response.rs index 3fcd6c85..72e55d0f 100644 --- a/paddler_client_cli/src/streaming_response.rs +++ b/paddler_client_cli/src/streaming_response.rs @@ -8,11 +8,11 @@ use crate::stop_reason::StopReason; #[derive(Debug, Default)] pub struct StreamingResponse { - pub thinking: String, - pub response: String, + pub thinking: Vec, + pub response: Vec, + pub tool_call_tokens: Vec, pub tool_calls: Vec, - pub pending_tool_call_buffer: String, - pub undetermined: String, + pub undetermined: Vec, pub summary: Option, pub stop_reason: Option, } @@ -55,14 +55,11 @@ impl StreamingResponse { fn apply_token_result(&mut self, token_result: GeneratedTokenResult) { match token_result { - GeneratedTokenResult::ContentToken(piece) => self.response.push_str(&piece), - GeneratedTokenResult::ReasoningToken(piece) => self.thinking.push_str(&piece), - GeneratedTokenResult::UndeterminableToken(piece) => self.undetermined.push_str(&piece), - GeneratedTokenResult::ToolCallToken(piece) => { - self.pending_tool_call_buffer.push_str(&piece); - } + GeneratedTokenResult::ContentToken(piece) => self.response.push(piece), + GeneratedTokenResult::ReasoningToken(piece) => self.thinking.push(piece), + GeneratedTokenResult::UndeterminableToken(piece) => self.undetermined.push(piece), + GeneratedTokenResult::ToolCallToken(piece) => self.tool_call_tokens.push(piece), GeneratedTokenResult::ToolCallParsed(calls) => { - self.pending_tool_call_buffer.clear(); self.tool_calls.extend(calls); } GeneratedTokenResult::Done(summary) => { @@ -123,7 +120,7 @@ mod tests { } #[test] - fn content_token_appends_to_response_buffer() { + fn content_token_appended_to_response_stream() { let mut state = StreamingResponse::default(); state.apply_message(token_message(GeneratedTokenResult::ContentToken( @@ -133,14 +130,17 @@ mod tests { "world".to_owned(), ))); - assert_eq!(state.response, "hello world"); + assert_eq!( + state.response, + vec!["hello ".to_owned(), "world".to_owned()] + ); assert!(state.thinking.is_empty()); assert!(state.undetermined.is_empty()); assert!(!state.is_finished()); } #[test] - fn raw_tool_call_token_appends_to_pending_buffer() { + fn raw_tool_call_token_appended_to_token_stream() { let mut state = StreamingResponse::default(); state.apply_message(token_message(GeneratedTokenResult::ToolCallToken( @@ -150,12 +150,15 @@ mod tests { "\"calc\"}".to_owned(), ))); - assert_eq!(state.pending_tool_call_buffer, "{\"name\":\"calc\"}"); + assert_eq!( + state.tool_call_tokens, + vec!["{\"name\":".to_owned(), "\"calc\"}".to_owned()] + ); assert!(state.tool_calls.is_empty()); } #[test] - fn tool_call_parsed_replaces_pending_buffer_with_structured_calls() { + fn tool_call_parsed_extends_calls_without_dropping_token_stream() { let mut state = StreamingResponse::default(); state.apply_message(token_message(GeneratedTokenResult::ToolCallToken( "{\"name\":\"calc\"}".to_owned(), @@ -167,7 +170,10 @@ mod tests { ))); assert_eq!(state.tool_calls, parsed); - assert!(state.pending_tool_call_buffer.is_empty()); + assert_eq!( + state.tool_call_tokens, + vec!["{\"name\":\"calc\"}".to_owned()] + ); } #[test] diff --git a/paddler_client_cli/src/render_chat_panels.rs b/paddler_client_cli/src/view_chat_panels.rs similarity index 51% rename from paddler_client_cli/src/render_chat_panels.rs rename to paddler_client_cli/src/view_chat_panels.rs index 0b73b105..80e99892 100644 --- a/paddler_client_cli/src/render_chat_panels.rs +++ b/paddler_client_cli/src/view_chat_panels.rs @@ -4,6 +4,11 @@ use paddler_types::generation_summary::GenerationSummary; use ratatui::Frame; use ratatui::layout::Margin; use ratatui::layout::Rect; +use ratatui::style::Color; +use ratatui::style::Style; +use ratatui::text::Line; +use ratatui::text::Span; +use ratatui::text::Text; use ratatui::widgets::Block; use ratatui::widgets::Paragraph; use ratatui::widgets::Scrollbar; @@ -11,56 +16,67 @@ use ratatui::widgets::ScrollbarOrientation; use ratatui::widgets::ScrollbarState; use ratatui::widgets::Wrap; -use crate::chat_panel_layout::ChatPanelLayout; -use crate::panel_kind::PanelKind; -use crate::panel_navigation::PanelNavigation; use crate::streaming_response::StreamingResponse; +use crate::view_panel_kind::ViewPanelKind; +use crate::view_panel_layout::ViewPanelLayout; +use crate::view_panel_navigation::ViewPanelNavigation; -pub fn render_chat_panels( +const TOKEN_PALETTE: [Color; 6] = [ + Color::LightCyan, + Color::LightYellow, + Color::LightMagenta, + Color::LightGreen, + Color::LightBlue, + Color::LightRed, +]; + +pub fn view_chat_panels( state: &StreamingResponse, - navigation: &mut PanelNavigation, - layout: &ChatPanelLayout, + navigation: &mut ViewPanelNavigation, + layout: &ViewPanelLayout, frame: &mut Frame<'_>, ) { - render_text_panel( + render_panel_text( frame, layout.thinking, - PanelKind::Thinking, - &state.thinking, + ViewPanelKind::Thinking, + Text::from(build_colored_token_lines(&state.thinking)), navigation, ); - render_text_panel( + render_panel_text( frame, layout.response, - PanelKind::Response, - &state.response, + ViewPanelKind::Response, + Text::from(build_colored_token_lines(&state.response)), navigation, ); - let formatted_tool_calls = - format_tool_calls(&state.tool_calls, &state.pending_tool_call_buffer); - render_text_panel( + render_panel_text( frame, layout.tool_calls, - PanelKind::ToolCalls, - &formatted_tool_calls, + ViewPanelKind::ToolCalls, + Text::from(build_tool_calls_lines( + &state.tool_call_tokens, + &state.tool_calls, + Block::bordered().inner(layout.tool_calls).width, + )), navigation, ); - render_text_panel( + render_panel_text( frame, layout.undetermined, - PanelKind::Undetermined, - &state.undetermined, + ViewPanelKind::Undetermined, + Text::from(build_colored_token_lines(&state.undetermined)), navigation, ); render_status_bar(frame, layout.status_bar, state); } -fn render_text_panel( +fn render_panel_text( frame: &mut Frame<'_>, area: Rect, - panel: PanelKind, - buffer: &str, - navigation: &mut PanelNavigation, + panel: ViewPanelKind, + text: Text<'_>, + navigation: &mut ViewPanelNavigation, ) { let title = if navigation.focused() == panel { format!("[ {} ]", panel.label()) @@ -69,12 +85,12 @@ fn render_text_panel( }; let block = Block::bordered().title(title); let inner = block.inner(area); - let visible_rows = visible_row_count(buffer, inner.width); + let visible_rows = count_text_rows(&text, inner.width); navigation.settle(panel, visible_rows.into(), inner.height.into()); let position = navigation.position(panel); let scroll_offset = u16::try_from(position).unwrap_or(u16::MAX); - let paragraph = Paragraph::new(buffer) + let paragraph = Paragraph::new(text) .wrap(Wrap { trim: false }) .scroll((scroll_offset, 0)) .block(block); @@ -100,29 +116,93 @@ fn render_text_panel( } } -fn render_status_bar(frame: &mut Frame<'_>, area: Rect, state: &StreamingResponse) { - let text = match (&state.stop_reason, &state.summary) { - (None, _) => { - "generating… · tab/shift-tab focus · ↑↓ pgup/pgdn home/end scroll · q quit".to_owned() +fn build_colored_token_lines(tokens: &[String]) -> Vec> { + let mut lines: Vec> = Vec::new(); + let mut current_line: Vec> = Vec::new(); + let mut had_token = false; + + for (token_index, token) in tokens.iter().enumerate() { + if token.is_empty() { + continue; } - (Some(_), Some(summary)) => format_completion_status(summary), - (Some(reason), None) => format!("stopped — {reason} · press q to quit"), - }; - frame.render_widget(Paragraph::new(text), area); + had_token = true; + let is_whitespace_only = token.chars().all(char::is_whitespace); + let style = if is_whitespace_only { + Style::default() + .bg(palette_color(token_index)) + .fg(Color::Black) + } else { + Style::default().fg(palette_color(token_index)) + }; + for (piece_index, piece) in token.split('\n').enumerate() { + if piece_index > 0 { + if is_whitespace_only { + current_line.push(Span::styled("↵", style)); + } + lines.push(Line::from(std::mem::take(&mut current_line))); + } + if !piece.is_empty() { + let rendered = if is_whitespace_only { + piece.replace('\t', "→") + } else { + piece.to_owned() + }; + if !rendered.is_empty() { + current_line.push(Span::styled(rendered, style)); + } + } + } + } + + if had_token { + lines.push(Line::from(current_line)); + } + + lines +} + +fn build_tool_calls_lines( + token_stream: &[String], + parsed_calls: &[ParsedToolCall], + inner_width: u16, +) -> Vec> { + let mut lines = build_colored_token_lines(token_stream); + let has_tokens = !token_stream.is_empty(); + let has_parsed = !parsed_calls.is_empty(); + if has_tokens && has_parsed { + lines.push(divider_line(inner_width)); + } + lines.extend(parsed_call_lines(parsed_calls)); + lines +} + +fn divider_line(width: u16) -> Line<'static> { + let label = " parsed "; + let label_width = u16::try_from(label.chars().count()).unwrap_or(u16::MAX); + let total = width.max(label_width.saturating_add(2)); + let dash_count = total.saturating_sub(label_width); + let left = dash_count / 2; + let right = dash_count - left; + let mut text = String::new(); + for _ in 0..left { + text.push('─'); + } + text.push_str(label); + for _ in 0..right { + text.push('─'); + } + Line::from(Span::styled(text, Style::default().fg(Color::Gray))) } -fn format_tool_calls(parsed_calls: &[ParsedToolCall], pending_raw: &str) -> String { - let mut output = String::new(); - for call in parsed_calls { - output.push_str(&call.name); - output.push('\n'); +fn parsed_call_lines(calls: &[ParsedToolCall]) -> Vec> { + let mut lines = Vec::new(); + for call in calls { + lines.push(Line::raw(call.name.clone())); match &call.arguments { ToolCallArguments::ValidJson(value) => match serde_json::to_string_pretty(value) { Ok(formatted) => { - for line in formatted.lines() { - output.push_str(" "); - output.push_str(line); - output.push('\n'); + for inner_line in formatted.lines() { + lines.push(Line::raw(format!(" {inner_line}"))); } } Err(format_error) => { @@ -130,22 +210,48 @@ fn format_tool_calls(parsed_calls: &[ParsedToolCall], pending_raw: &str) -> Stri "failed to pretty-print tool-call arguments for {name}: {format_error}", name = call.name ); - output.push_str(" "); - output.push_str(&value.to_string()); - output.push('\n'); + lines.push(Line::raw(format!(" {value}"))); } }, ToolCallArguments::InvalidJson(raw) => { - output.push_str(" invalid JSON: "); - output.push_str(raw); - output.push('\n'); + lines.push(Line::raw(format!(" invalid JSON: {raw}"))); } } } - if !pending_raw.is_empty() { - output.push_str(pending_raw); + lines +} + +const fn palette_color(token_index: usize) -> Color { + TOKEN_PALETTE[token_index % TOKEN_PALETTE.len()] +} + +fn count_text_rows(text: &Text<'_>, width: u16) -> u16 { + if width == 0 { + return 0; + } + let mut total: u16 = 0; + for line in &text.lines { + let chars_count: usize = line + .spans + .iter() + .map(|span| span.content.chars().count()) + .sum(); + let chars = u16::try_from(chars_count).unwrap_or(u16::MAX); + let rows = chars.div_ceil(width).max(1); + total = total.saturating_add(rows); } - output + total +} + +fn render_status_bar(frame: &mut Frame<'_>, area: Rect, state: &StreamingResponse) { + let text = match (&state.stop_reason, &state.summary) { + (None, _) => { + "generating… · tab/shift-tab focus · ↑↓ pgup/pgdn home/end scroll · q quit".to_owned() + } + (Some(_), Some(summary)) => format_completion_status(summary), + (Some(reason), None) => format!("stopped — {reason} · press q to quit"), + }; + frame.render_widget(Paragraph::new(text), area); } fn format_completion_status(summary: &GenerationSummary) -> String { @@ -161,19 +267,6 @@ fn format_completion_status(summary: &GenerationSummary) -> String { ) } -fn visible_row_count(buffer: &str, width: u16) -> u16 { - if width == 0 || buffer.is_empty() { - return 0; - } - let mut total: u16 = 0; - for line in buffer.split('\n') { - let char_count = u16::try_from(line.chars().count()).unwrap_or(u16::MAX); - let rows = char_count.div_ceil(width).max(1); - total = total.saturating_add(rows); - } - total -} - #[cfg(test)] mod tests { use anyhow::Result; @@ -186,11 +279,11 @@ mod tests { use crate::stop_reason::StopReason; fn render_to_string(state: &StreamingResponse, width: u16, height: u16) -> Result { - let mut navigation = PanelNavigation::default(); + let mut navigation = ViewPanelNavigation::default(); let mut terminal = Terminal::new(TestBackend::new(width, height))?; terminal.draw(|frame| { - let layout = ChatPanelLayout::compute(frame.area()); - render_chat_panels(state, &mut navigation, &layout, frame); + let layout = ViewPanelLayout::compute(frame.area()); + view_chat_panels(state, &mut navigation, &layout, frame); })?; Ok(buffer_text(terminal.backend().buffer())) } @@ -236,7 +329,7 @@ mod tests { #[test] fn response_buffer_text_is_visible() -> Result<()> { let mut state = StreamingResponse::default(); - state.response.push_str("hello world"); + state.response.push("hello world".to_owned()); let rendered = render_to_string(&state, 80, 30)?; @@ -259,4 +352,31 @@ mod tests { assert!(!rendered.contains("generating")); Ok(()) } + + #[test] + fn whitespace_only_newline_token_renders_return_marker() -> Result<()> { + let mut state = StreamingResponse::default(); + state.response.push("hello".to_owned()); + state.response.push("\n".to_owned()); + state.response.push("world".to_owned()); + + let rendered = render_to_string(&state, 80, 30)?; + + assert!(rendered.contains("↵")); + Ok(()) + } + + #[test] + fn tool_calls_panel_shows_divider_when_tokens_and_parsed_both_present() -> Result<()> { + let mut state = StreamingResponse::default(); + state + .tool_call_tokens + .push("{\"name\":\"calc\"}".to_owned()); + state.tool_calls.push(ParsedToolCall::default()); + + let rendered = render_to_string(&state, 120, 30)?; + + assert!(rendered.contains("parsed")); + Ok(()) + } } diff --git a/paddler_client_cli/src/panel_kind.rs b/paddler_client_cli/src/view_panel_kind.rs similarity index 89% rename from paddler_client_cli/src/panel_kind.rs rename to paddler_client_cli/src/view_panel_kind.rs index a00773cb..0a8eac0e 100644 --- a/paddler_client_cli/src/panel_kind.rs +++ b/paddler_client_cli/src/view_panel_kind.rs @@ -1,13 +1,13 @@ #[repr(u8)] #[derive(Clone, Copy, Debug, Eq, PartialEq)] -pub enum PanelKind { +pub enum ViewPanelKind { Thinking = 0, Response = 1, ToolCalls = 2, Undetermined = 3, } -impl PanelKind { +impl ViewPanelKind { pub const fn label(self) -> &'static str { match self { Self::Thinking => "Thinking", diff --git a/paddler_client_cli/src/chat_panel_layout.rs b/paddler_client_cli/src/view_panel_layout.rs similarity index 65% rename from paddler_client_cli/src/chat_panel_layout.rs rename to paddler_client_cli/src/view_panel_layout.rs index 022e4de1..0873dda1 100644 --- a/paddler_client_cli/src/chat_panel_layout.rs +++ b/paddler_client_cli/src/view_panel_layout.rs @@ -3,11 +3,11 @@ use ratatui::layout::Layout; use ratatui::layout::Position; use ratatui::layout::Rect; -use crate::panel_kind::PanelKind; +use crate::view_panel_kind::ViewPanelKind; const STATUS_BAR_HEIGHT: u16 = 1; -pub struct ChatPanelLayout { +pub struct ViewPanelLayout { pub thinking: Rect, pub response: Rect, pub tool_calls: Rect, @@ -15,7 +15,7 @@ pub struct ChatPanelLayout { pub status_bar: Rect, } -impl ChatPanelLayout { +impl ViewPanelLayout { pub fn compute(area: Rect) -> Self { let outer = Layout::vertical([Constraint::Min(0), Constraint::Length(STATUS_BAR_HEIGHT)]) .split(area); @@ -34,26 +34,26 @@ impl ChatPanelLayout { } } - pub const fn rect_for(&self, panel: PanelKind) -> Rect { + pub const fn rect_for(&self, panel: ViewPanelKind) -> Rect { match panel { - PanelKind::Thinking => self.thinking, - PanelKind::Response => self.response, - PanelKind::ToolCalls => self.tool_calls, - PanelKind::Undetermined => self.undetermined, + ViewPanelKind::Thinking => self.thinking, + ViewPanelKind::Response => self.response, + ViewPanelKind::ToolCalls => self.tool_calls, + ViewPanelKind::Undetermined => self.undetermined, } } - pub const fn viewport_rows(&self, panel: PanelKind) -> u16 { + pub const fn viewport_rows(&self, panel: ViewPanelKind) -> u16 { self.rect_for(panel).height.saturating_sub(2) } - pub fn panel_at(&self, column: u16, row: u16) -> Option { + pub fn panel_at(&self, column: u16, row: u16) -> Option { let position = Position { x: column, y: row }; [ - PanelKind::Thinking, - PanelKind::Response, - PanelKind::ToolCalls, - PanelKind::Undetermined, + ViewPanelKind::Thinking, + ViewPanelKind::Response, + ViewPanelKind::ToolCalls, + ViewPanelKind::Undetermined, ] .into_iter() .find(|panel| self.rect_for(*panel).contains(position)) diff --git a/paddler_client_cli/src/view_panel_navigation.rs b/paddler_client_cli/src/view_panel_navigation.rs new file mode 100644 index 00000000..62416c7b --- /dev/null +++ b/paddler_client_cli/src/view_panel_navigation.rs @@ -0,0 +1,185 @@ +use crate::view_panel_kind::ViewPanelKind; + +const PANEL_COUNT: usize = 4; + +pub struct ViewPanelNavigation { + focused: ViewPanelKind, + views: [PanelView; PANEL_COUNT], +} + +#[derive(Clone, Copy)] +struct PanelView { + position: usize, + follow_bottom: bool, +} + +impl Default for PanelView { + fn default() -> Self { + Self { + position: 0, + follow_bottom: true, + } + } +} + +impl Default for ViewPanelNavigation { + fn default() -> Self { + Self { + focused: ViewPanelKind::Response, + views: [PanelView::default(); PANEL_COUNT], + } + } +} + +impl ViewPanelNavigation { + pub const fn focused(&self) -> ViewPanelKind { + self.focused + } + + pub const fn focus(&mut self, panel: ViewPanelKind) { + self.focused = panel; + } + + pub const fn cycle_focus_forward(&mut self) { + self.focused = match self.focused { + ViewPanelKind::Thinking => ViewPanelKind::Response, + ViewPanelKind::Response => ViewPanelKind::ToolCalls, + ViewPanelKind::ToolCalls => ViewPanelKind::Undetermined, + ViewPanelKind::Undetermined => ViewPanelKind::Thinking, + }; + } + + pub const fn cycle_focus_backward(&mut self) { + self.focused = match self.focused { + ViewPanelKind::Thinking => ViewPanelKind::Undetermined, + ViewPanelKind::Response => ViewPanelKind::Thinking, + ViewPanelKind::ToolCalls => ViewPanelKind::Response, + ViewPanelKind::Undetermined => ViewPanelKind::ToolCalls, + }; + } + + pub fn scroll_up(&mut self, panel: ViewPanelKind, lines: u16) { + let view = &mut self.views[panel as usize]; + view.follow_bottom = false; + view.position = view.position.saturating_sub(lines.into()); + } + + pub fn scroll_down(&mut self, panel: ViewPanelKind, lines: u16) { + let view = &mut self.views[panel as usize]; + view.position = view.position.saturating_add(lines.into()); + } + + pub const fn jump_to_top(&mut self, panel: ViewPanelKind) { + let view = &mut self.views[panel as usize]; + view.follow_bottom = false; + view.position = 0; + } + + pub const fn jump_to_bottom(&mut self, panel: ViewPanelKind) { + self.views[panel as usize].follow_bottom = true; + } + + pub const fn settle( + &mut self, + panel: ViewPanelKind, + content_rows: usize, + viewport_rows: usize, + ) { + let view = &mut self.views[panel as usize]; + let max_position = content_rows.saturating_sub(viewport_rows); + if view.follow_bottom || view.position >= max_position { + view.position = max_position; + view.follow_bottom = true; + } + } + + pub const fn position(&self, panel: ViewPanelKind) -> usize { + self.views[panel as usize].position + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn defaults_focus_response_and_follow_bottom() { + let mut nav = ViewPanelNavigation::default(); + + assert_eq!(nav.focused(), ViewPanelKind::Response); + nav.settle(ViewPanelKind::Response, 100, 10); + assert_eq!(nav.position(ViewPanelKind::Response), 90); + } + + #[test] + fn scroll_up_disengages_follow_and_decrements_position() { + let mut nav = ViewPanelNavigation::default(); + + nav.scroll_up(ViewPanelKind::Response, 5); + + nav.settle(ViewPanelKind::Response, 100, 10); + assert_eq!(nav.position(ViewPanelKind::Response), 0); + } + + #[test] + fn scroll_down_after_scroll_up_advances_within_content() { + let mut nav = ViewPanelNavigation::default(); + nav.scroll_up(ViewPanelKind::Response, 50); + + nav.scroll_down(ViewPanelKind::Response, 10); + + nav.settle(ViewPanelKind::Response, 100, 10); + assert_eq!(nav.position(ViewPanelKind::Response), 10); + } + + #[test] + fn jump_to_bottom_re_engages_auto_follow() { + let mut nav = ViewPanelNavigation::default(); + nav.scroll_up(ViewPanelKind::Response, 50); + + nav.jump_to_bottom(ViewPanelKind::Response); + + nav.settle(ViewPanelKind::Response, 200, 10); + assert_eq!(nav.position(ViewPanelKind::Response), 190); + } + + #[test] + fn cycle_focus_forward_walks_panels_in_reading_order() { + let mut nav = ViewPanelNavigation::default(); + + nav.cycle_focus_forward(); + assert_eq!(nav.focused(), ViewPanelKind::ToolCalls); + nav.cycle_focus_forward(); + assert_eq!(nav.focused(), ViewPanelKind::Undetermined); + nav.cycle_focus_forward(); + assert_eq!(nav.focused(), ViewPanelKind::Thinking); + nav.cycle_focus_forward(); + assert_eq!(nav.focused(), ViewPanelKind::Response); + } + + #[test] + fn scrolling_back_to_bottom_re_engages_auto_follow_for_subsequent_growth() { + let mut nav = ViewPanelNavigation::default(); + nav.settle(ViewPanelKind::Response, 100, 10); + + nav.scroll_up(ViewPanelKind::Response, 5); + nav.settle(ViewPanelKind::Response, 100, 10); + + nav.scroll_down(ViewPanelKind::Response, 10); + nav.settle(ViewPanelKind::Response, 100, 10); + + nav.settle(ViewPanelKind::Response, 110, 10); + + assert_eq!(nav.position(ViewPanelKind::Response), 100); + } + + #[test] + fn position_is_clamped_when_content_shorter_than_stored_offset() { + let mut nav = ViewPanelNavigation::default(); + nav.scroll_up(ViewPanelKind::Response, 0); + nav.scroll_down(ViewPanelKind::Response, 80); + + nav.settle(ViewPanelKind::Response, 30, 10); + assert_eq!(nav.position(ViewPanelKind::Response), 20); + } +} diff --git a/paddler_client_cli/src/raw_terminal_guard.rs b/paddler_client_cli/src/view_terminal_guard.rs similarity index 96% rename from paddler_client_cli/src/raw_terminal_guard.rs rename to paddler_client_cli/src/view_terminal_guard.rs index aef02c71..d23f6bba 100644 --- a/paddler_client_cli/src/raw_terminal_guard.rs +++ b/paddler_client_cli/src/view_terminal_guard.rs @@ -10,9 +10,9 @@ use crossterm::terminal::LeaveAlternateScreen; use crossterm::terminal::disable_raw_mode; use crossterm::terminal::enable_raw_mode; -pub struct RawTerminalGuard; +pub struct ViewTerminalGuard; -impl RawTerminalGuard { +impl ViewTerminalGuard { pub fn enter() -> Result { enable_raw_mode().context("enabling raw mode")?; if let Err(enter_alt_screen_error) = io::stdout().execute(EnterAlternateScreen) { @@ -42,7 +42,7 @@ impl RawTerminalGuard { } } -impl Drop for RawTerminalGuard { +impl Drop for ViewTerminalGuard { fn drop(&mut self) { if let Err(disable_mouse_error) = io::stdout().execute(DisableMouseCapture) { log::error!("failed to disable mouse capture: {disable_mouse_error}"); From 72077ae4b0942750b7f5262c487edc4e5bbeb56c Mon Sep 17 00:00:00 2001 From: Mateusz Charytoniuk Date: Sat, 9 May 2026 23:43:17 +0200 Subject: [PATCH 28/51] attribute every response to its producing agent via envelope.generated_by --- .../agent/management_socket_client_service.rs | 3 + .../http_route/post_chat_completions.rs | 7 ++ .../api/post_generate_embedding_batch.rs | 1 + .../http_route/api/ws_agent_socket/mod.rs | 4 + paddler/src/balancer/request_from_agent.rs | 1 + paddler_client_cli/src/streaming_response.rs | 1 + .../InferenceServiceGenerateTokensResponse.ts | 42 ++++++--- .../paddler_client/inference_message.py | 18 ++++ .../src/collect_embedding_results.rs | 59 +++++++------ paddler_tests/src/collect_generated_tokens.rs | 53 +++++++----- .../src/collected_embedding_results.rs | 4 +- .../src/collected_generated_tokens.rs | 4 +- paddler_tests/src/embedding_with_producer.rs | 7 ++ paddler_tests/src/lib.rs | 2 + .../src/token_result_with_producer.rs | 7 ++ ..._embedding_batch_larger_than_slot_count.rs | 8 +- ...rent_dispatch_evenly_across_idle_agents.rs | 65 ++++++++++++++ ...t_conversation_accepts_empty_tools_list.rs | 2 +- ...onversation_history_respects_max_tokens.rs | 2 +- ...istribution_independent_of_context_size.rs | 2 +- ...eturns_one_embedding_per_input_document.rs | 2 +- ...mension_across_inputs_of_varying_length.rs | 10 +-- ...ith_thinking_returns_incompatible_error.rs | 2 +- ...oncurrent_embedding_requests_per_client.rs | 2 +- ...l2_normalized_embeddings_have_unit_norm.rs | 7 +- .../agent_raw_prompt_respects_max_tokens.rs | 2 +- ...ical_embeddings_for_identical_documents.rs | 6 +- ...image_decoding_error_for_invalid_base64.rs | 10 ++- ...e_decoding_error_for_malformed_data_uri.rs | 10 ++- ...rns_image_decoding_error_for_remote_url.rs | 10 ++- ...ms_normalized_embeddings_when_requested.rs | 2 +- ..._unnormalized_embeddings_when_requested.rs | 2 +- ...our_concurrent_clients_streaming_tokens.rs | 2 +- ...ens_from_conversation_history_over_http.rs | 2 +- ...gent_streams_tokens_from_image_data_uri.rs | 3 +- .../agent_streams_tokens_from_raw_prompt.rs | 2 +- ...ent_text_only_model_rejects_image_input.rs | 2 +- ...stributes_embedding_batch_across_agents.rs | 48 +++-------- ...es_embedding_burst_evenly_across_agents.rs | 86 +++++++++++++++++++ ...ibutes_token_burst_evenly_across_agents.rs | 85 ++++++++++++++++++ ..._fans_out_embedding_batch_to_all_agents.rs | 48 +++-------- ..._drains_in_flight_inference_before_swap.rs | 16 ++-- ...emplate_override_replaces_model_builtin.rs | 3 +- ..._template_swaps_between_inference_calls.rs | 3 +- ..._conversation_history_requests_complete.rs | 15 +++- ..._evicts_long_sequence_under_kv_pressure.rs | 8 +- ...kens_with_distinct_k_and_v_cache_dtypes.rs | 8 +- ...rates_tokens_with_partial_layer_offload.rs | 8 +- ...and_short_prompts_complete_concurrently.rs | 15 +++- ...h_plain_and_multimodal_run_concurrently.rs | 10 ++- ...tch_reuses_slot_after_request_completes.rs | 13 ++- ...s_batch_serves_four_concurrent_requests.rs | 8 +- paddler_tests/tests/continuous_batch_smoke.rs | 8 +- ...uous_batch_stops_at_max_tokens_boundary.rs | 8 +- ...ops_generation_when_stop_sender_dropped.rs | 8 +- ...rent_multimodal_requests_produce_tokens.rs | 10 ++- ...nternal_endpoint_emits_reasoning_tokens.rs | 8 +- ...nternal_endpoint_emits_reasoning_tokens.rs | 8 +- ...mits_reasoning_tokens_for_image_request.rs | 4 +- ...l_endpoint_emits_tool_call_parsed_event.rs | 2 +- ...nternal_endpoint_emits_reasoning_tokens.rs | 8 +- ...l_endpoint_emits_tool_call_parsed_event.rs | 2 +- ...nternal_endpoint_emits_reasoning_tokens.rs | 8 +- ...mits_reasoning_tokens_for_image_request.rs | 4 +- ...l_endpoint_emits_tool_call_parsed_event.rs | 2 +- ...n25vl_generates_tokens_from_image_input.rs | 8 +- ..._tokens_for_long_system_and_user_prompt.rs | 8 +- ...neration_stops_at_eog_before_max_tokens.rs | 8 +- ...mits_reasoning_tokens_for_image_request.rs | 4 +- ...l_endpoint_emits_tool_call_parsed_event.rs | 2 +- ...king_disabled_emits_only_content_tokens.rs | 17 ++-- ...thinking_enabled_emits_reasoning_tokens.rs | 8 +- ...ng_mode_stops_cleanly_before_max_tokens.rs | 8 +- ...g_multi_turn_conversation_stops_cleanly.rs | 8 +- ...with_mmproj_generates_tokens_from_image.rs | 8 +- ..._system_message_completes_with_thinking.rs | 8 +- ...stem_message_completes_without_thinking.rs | 8 +- ...cts_image_with_multimodal_not_supported.rs | 2 +- ...mits_reasoning_tokens_for_image_request.rs | 4 +- ...l_endpoint_emits_tool_call_parsed_event.rs | 2 +- ...king_disabled_emits_only_content_tokens.rs | 17 ++-- ...thinking_enabled_emits_reasoning_tokens.rs | 8 +- ...erates_tokens_from_conversation_history.rs | 8 +- .../qwen3_generates_tokens_from_raw_prompt.rs | 8 +- ...ith_thinking_returns_incompatible_error.rs | 2 +- ...t_concurrent_requests_independent_usage.rs | 2 +- ...l_endpoint_emits_tool_call_parsed_event.rs | 2 +- ...nternal_endpoint_emits_tool_call_tokens.rs | 6 +- ...ernal_endpoint_max_tokens_usage_matches.rs | 4 +- ...n3_internal_endpoint_pure_content_usage.rs | 2 +- ...without_parse_flag_emit_only_raw_tokens.rs | 4 +- ...king_disabled_emits_no_reasoning_tokens.rs | 6 +- ...thinking_enabled_emits_reasoning_tokens.rs | 4 +- ..._grammar_generates_unconstrained_output.rs | 2 +- ...lvlm2_generates_tokens_from_image_input.rs | 8 +- .../src/jsonrpc/response_envelope.rs | 1 + 96 files changed, 705 insertions(+), 304 deletions(-) create mode 100644 paddler_tests/src/embedding_with_producer.rs create mode 100644 paddler_tests/src/token_result_with_producer.rs create mode 100644 paddler_tests/tests/agent_controller_pool_distributes_concurrent_dispatch_evenly_across_idle_agents.rs create mode 100644 paddler_tests/tests/balancer_distributes_embedding_burst_evenly_across_agents.rs create mode 100644 paddler_tests/tests/balancer_distributes_token_burst_evenly_across_agents.rs diff --git a/paddler/src/agent/management_socket_client_service.rs b/paddler/src/agent/management_socket_client_service.rs index e7c5bc06..155f2f04 100644 --- a/paddler/src/agent/management_socket_client_service.rs +++ b/paddler/src/agent/management_socket_client_service.rs @@ -107,6 +107,7 @@ impl ManagementSocketClientService { message_tx.send( ManagementJsonRpcMessage::Response( ResponseEnvelope { + generated_by: None, request_id: id.clone(), response: response.into(), } @@ -228,6 +229,7 @@ impl ManagementSocketClientService { request: JsonRpcRequest::GetChatTemplateOverride, }) => Ok( message_tx.send(ManagementJsonRpcMessage::Response(ResponseEnvelope { + generated_by: None, request_id: id, response: JsonRpcResponse::ChatTemplateOverride( if let Some(agent_applicable_state) = @@ -245,6 +247,7 @@ impl ManagementSocketClientService { request: JsonRpcRequest::GetModelMetadata, }) => Ok( message_tx.send(ManagementJsonRpcMessage::Response(ResponseEnvelope { + generated_by: None, request_id: id, response: JsonRpcResponse::ModelMetadata( model_metadata_holder.get_model_metadata(), diff --git a/paddler/src/balancer/compatibility/openai_service/http_route/post_chat_completions.rs b/paddler/src/balancer/compatibility/openai_service/http_route/post_chat_completions.rs index a8661d24..36d94eac 100644 --- a/paddler/src/balancer/compatibility/openai_service/http_route/post_chat_completions.rs +++ b/paddler/src/balancer/compatibility/openai_service/http_route/post_chat_completions.rs @@ -384,11 +384,13 @@ impl TransformsOutgoingMessage for OpenAIStreamingResponseTransformer { GeneratedTokenResult::ContentToken(text) | GeneratedTokenResult::UndeterminableToken(text), ), + .. }) => self.handle_content(&request_id, &text), OutgoingMessage::Response(ResponseEnvelope { request_id, response: OutgoingResponse::GeneratedToken(GeneratedTokenResult::ReasoningToken(text)), + .. }) => self.handle_reasoning(&request_id, &text), OutgoingMessage::Response(ResponseEnvelope { response: OutgoingResponse::GeneratedToken(GeneratedTokenResult::ToolCallToken(_)), @@ -398,6 +400,7 @@ impl TransformsOutgoingMessage for OpenAIStreamingResponseTransformer { request_id, response: OutgoingResponse::GeneratedToken(GeneratedTokenResult::ToolCallParsed(parsed_calls)), + .. }) => self.handle_tool_call_parsed(&request_id, &parsed_calls), OutgoingMessage::Response(ResponseEnvelope { response: @@ -411,6 +414,7 @@ impl TransformsOutgoingMessage for OpenAIStreamingResponseTransformer { OutgoingMessage::Response(ResponseEnvelope { request_id, response: OutgoingResponse::GeneratedToken(GeneratedTokenResult::Done(summary)), + .. }) => self.handle_done(&request_id, &summary), other => Err(anyhow!( "OpenAIStreamingResponseTransformer received an outgoing message it does not know how to handle: {other:?}" @@ -580,6 +584,7 @@ impl TransformsOutgoingMessage for OpenAINonStreamingResponseTransformer { OutgoingMessage::Response(ResponseEnvelope { request_id, response: OutgoingResponse::GeneratedToken(GeneratedTokenResult::Done(summary)), + .. }) => Ok(vec![TransformResult::Chunk( self.build_done_chunk(&request_id, &summary)?, )]), @@ -711,6 +716,7 @@ mod tests { fn make_token_message(token_result: GeneratedTokenResult) -> OutgoingMessage { OutgoingMessage::Response(ResponseEnvelope { + generated_by: None, request_id: "test-request".to_owned(), response: OutgoingResponse::GeneratedToken(token_result), }) @@ -728,6 +734,7 @@ mod tests { fn make_response_message(response: OutgoingResponse) -> OutgoingMessage { OutgoingMessage::Response(ResponseEnvelope { + generated_by: None, request_id: "test-request".to_owned(), response, }) diff --git a/paddler/src/balancer/inference_service/http_route/api/post_generate_embedding_batch.rs b/paddler/src/balancer/inference_service/http_route/api/post_generate_embedding_batch.rs index f2940e55..c3b5de80 100644 --- a/paddler/src/balancer/inference_service/http_route/api/post_generate_embedding_batch.rs +++ b/paddler/src/balancer/inference_service/http_route/api/post_generate_embedding_batch.rs @@ -136,6 +136,7 @@ async fn respond( final_session .send_response_safe(OutgoingMessage::Response(ResponseEnvelope { + generated_by: None, request_id: final_request_id, response: OutgoingResponse::Embedding(EmbeddingResult::Done), })) diff --git a/paddler/src/balancer/management_service/http_route/api/ws_agent_socket/mod.rs b/paddler/src/balancer/management_service/http_route/api/ws_agent_socket/mod.rs index 1790f42f..db057885 100644 --- a/paddler/src/balancer/management_service/http_route/api/ws_agent_socket/mod.rs +++ b/paddler/src/balancer/management_service/http_route/api/ws_agent_socket/mod.rs @@ -233,6 +233,7 @@ impl ControlsWebSocketEndpoint for AgentSocketController { ManagementJsonRpcMessage::Response(ResponseEnvelope { request_id, response: AgentJsonRpcResponse::ChatTemplateOverride(chat_template_override), + .. }) => { context .chat_template_override_sender_collection @@ -244,6 +245,7 @@ impl ControlsWebSocketEndpoint for AgentSocketController { ManagementJsonRpcMessage::Response(ResponseEnvelope { request_id, response: AgentJsonRpcResponse::Embedding(embedding_result), + .. }) => { context .embedding_sender_collection @@ -255,6 +257,7 @@ impl ControlsWebSocketEndpoint for AgentSocketController { ManagementJsonRpcMessage::Response(ResponseEnvelope { request_id, response: AgentJsonRpcResponse::GeneratedToken(generated_token_envelope), + .. }) => { context .generate_tokens_sender_collection @@ -266,6 +269,7 @@ impl ControlsWebSocketEndpoint for AgentSocketController { ManagementJsonRpcMessage::Response(ResponseEnvelope { request_id, response: AgentJsonRpcResponse::ModelMetadata(model_metadata), + .. }) => { context .model_metadata_sender_collection diff --git a/paddler/src/balancer/request_from_agent.rs b/paddler/src/balancer/request_from_agent.rs index af1e8971..346cb0b1 100644 --- a/paddler/src/balancer/request_from_agent.rs +++ b/paddler/src/balancer/request_from_agent.rs @@ -208,6 +208,7 @@ where { if let Err(err) = session_controller .send_response(OutgoingMessage::Response(ResponseEnvelope { + generated_by: agent_controller.name.clone(), request_id: request_id.clone(), response: response.into(), })) diff --git a/paddler_client_cli/src/streaming_response.rs b/paddler_client_cli/src/streaming_response.rs index 72e55d0f..48007bf8 100644 --- a/paddler_client_cli/src/streaming_response.rs +++ b/paddler_client_cli/src/streaming_response.rs @@ -114,6 +114,7 @@ mod tests { fn token_message(token_result: GeneratedTokenResult) -> Message { Message::Response(ResponseEnvelope { + generated_by: None, request_id: "test-request".to_owned(), response: Response::GeneratedToken(token_result), }) diff --git a/paddler_client_javascript/src/schemas/InferenceServiceGenerateTokensResponse.ts b/paddler_client_javascript/src/schemas/InferenceServiceGenerateTokensResponse.ts index 89527836..1fe573af 100644 --- a/paddler_client_javascript/src/schemas/InferenceServiceGenerateTokensResponse.ts +++ b/paddler_client_javascript/src/schemas/InferenceServiceGenerateTokensResponse.ts @@ -47,6 +47,7 @@ type Normalised = | { done: true; error: null; + generated_by: string | null; ok: true; request_id: string; summary: z.infer; @@ -57,6 +58,7 @@ type Normalised = | { done: false; error: null; + generated_by: string | null; ok: true; request_id: string; summary: null; @@ -67,6 +69,7 @@ type Normalised = | { done: false; error: null; + generated_by: string | null; ok: true; request_id: string; summary: null; @@ -77,6 +80,7 @@ type Normalised = | { done: true; error: { code: number; description: string }; + generated_by: string | null; ok: false; request_id: string; summary: null; @@ -87,6 +91,7 @@ type Normalised = | { done: false; error: { code: number; description: string }; + generated_by: string | null; ok: false; request_id: string; summary: null; @@ -97,12 +102,14 @@ type Normalised = function terminalError( request_id: string, + generated_by: string | null, code: number, description: string, ): Normalised { return Object.freeze({ done: true, error: Object.freeze({ code, description }), + generated_by, ok: false, request_id, summary: null, @@ -114,12 +121,14 @@ function terminalError( function nonTerminalError( request_id: string, + generated_by: string | null, code: number, description: string, ): Normalised { return Object.freeze({ done: false, error: Object.freeze({ code, description }), + generated_by, ok: false, request_id, summary: null, @@ -131,12 +140,14 @@ function nonTerminalError( function streamingToken( request_id: string, + generated_by: string | null, token: string, tokenKind: GeneratedTokenKind, ): Normalised { return Object.freeze({ done: false, error: null, + generated_by, ok: true, request_id, summary: null, @@ -159,6 +170,7 @@ export const InferenceServiceGenerateTokensResponseSchema = z }), z.object({ Response: z.object({ + generated_by: z.string().nullable(), request_id: z.string(), response: z.object({ GeneratedToken: GeneratedTokenResultSchema, @@ -170,29 +182,32 @@ export const InferenceServiceGenerateTokensResponseSchema = z if ("Error" in data) { return terminalError( data.Error.request_id, + null, data.Error.error.code, data.Error.error.description, ); } const request_id = data.Response.request_id; + const generated_by = data.Response.generated_by; const variant = data.Response.response.GeneratedToken; if ("ContentToken" in variant) { - return streamingToken(request_id, variant.ContentToken, "content"); + return streamingToken(request_id, generated_by, variant.ContentToken, "content"); } if ("ReasoningToken" in variant) { - return streamingToken(request_id, variant.ReasoningToken, "reasoning"); + return streamingToken(request_id, generated_by, variant.ReasoningToken, "reasoning"); } if ("ToolCallToken" in variant) { - return streamingToken(request_id, variant.ToolCallToken, "tool_call"); + return streamingToken(request_id, generated_by, variant.ToolCallToken, "tool_call"); } if ("UndeterminableToken" in variant) { return streamingToken( request_id, + generated_by, variant.UndeterminableToken, "undeterminable", ); @@ -202,6 +217,7 @@ export const InferenceServiceGenerateTokensResponseSchema = z return Object.freeze({ done: true, error: null, + generated_by, ok: true, request_id, summary: variant.Done, @@ -215,6 +231,7 @@ export const InferenceServiceGenerateTokensResponseSchema = z return Object.freeze({ done: false, error: null, + generated_by, ok: true, request_id, summary: null, @@ -225,12 +242,13 @@ export const InferenceServiceGenerateTokensResponseSchema = z } if ("ToolCallParseFailed" in variant) { - return nonTerminalError(request_id, 422, variant.ToolCallParseFailed); + return nonTerminalError(request_id, generated_by, 422, variant.ToolCallParseFailed); } if ("ToolCallValidationFailed" in variant) { return nonTerminalError( request_id, + generated_by, 422, variant.ToolCallValidationFailed.join("; "), ); @@ -239,44 +257,46 @@ export const InferenceServiceGenerateTokensResponseSchema = z if ("ToolCallValidatorBuildFailed" in variant) { return terminalError( request_id, + generated_by, 400, variant.ToolCallValidatorBuildFailed, ); } if ("ChatTemplateError" in variant) { - return terminalError(request_id, 500, variant.ChatTemplateError); + return terminalError(request_id, generated_by, 500, variant.ChatTemplateError); } if ("GrammarIncompatibleWithThinking" in variant) { return terminalError( request_id, + generated_by, 400, variant.GrammarIncompatibleWithThinking, ); } if ("GrammarInitializationFailed" in variant) { - return terminalError(request_id, 500, variant.GrammarInitializationFailed); + return terminalError(request_id, generated_by, 500, variant.GrammarInitializationFailed); } if ("GrammarRejectedModelOutput" in variant) { - return terminalError(request_id, 500, variant.GrammarRejectedModelOutput); + return terminalError(request_id, generated_by, 500, variant.GrammarRejectedModelOutput); } if ("GrammarSyntaxError" in variant) { - return terminalError(request_id, 400, variant.GrammarSyntaxError); + return terminalError(request_id, generated_by, 400, variant.GrammarSyntaxError); } if ("ImageDecodingFailed" in variant) { - return terminalError(request_id, 400, variant.ImageDecodingFailed); + return terminalError(request_id, generated_by, 400, variant.ImageDecodingFailed); } if ("MultimodalNotSupported" in variant) { - return terminalError(request_id, 400, variant.MultimodalNotSupported); + return terminalError(request_id, generated_by, 400, variant.MultimodalNotSupported); } - return terminalError(request_id, 500, variant.SamplerError); + return terminalError(request_id, generated_by, 500, variant.SamplerError); }); export type InferenceServiceGenerateTokensResponse = z.infer< diff --git a/paddler_client_python/paddler_client/inference_message.py b/paddler_client_python/paddler_client/inference_message.py index 8023a8b6..f3bc248a 100644 --- a/paddler_client_python/paddler_client/inference_message.py +++ b/paddler_client_python/paddler_client/inference_message.py @@ -103,6 +103,7 @@ class InferenceMessage: error_code: int | None = None summary: GenerationSummary | None = None parsed_tool_calls: list[ParsedToolCall] | None = None + generated_by: str | None = None @property def is_token(self) -> bool: @@ -136,6 +137,7 @@ def parse_inference_client_message( return _parse_response( response_envelope["request_id"], response_envelope["response"], + response_envelope.get("generated_by"), ) msg = f"Unknown inference client message format: {data}" @@ -158,18 +160,21 @@ def _parse_error_envelope( def _parse_response( request_id: str, response: str | dict[str, Any], + generated_by: str | None, ) -> InferenceMessage: if isinstance(response, str): if response == "Timeout": return InferenceMessage( request_id=request_id, kind=InferenceMessageKind.TIMEOUT, + generated_by=generated_by, ) if response == "TooManyBufferedRequests": return InferenceMessage( request_id=request_id, kind=InferenceMessageKind.TOO_MANY_BUFFERED_REQUESTS, + generated_by=generated_by, ) msg = f"Unknown response variant: {response}" @@ -179,12 +184,14 @@ def _parse_response( return _parse_generated_token_result( request_id, response["GeneratedToken"], + generated_by, ) if "Embedding" in response: return _parse_embedding_result( request_id, response["Embedding"], + generated_by, ) msg = f"Unknown response: {response}" @@ -217,6 +224,7 @@ def _parse_response( def _parse_generated_token_result( request_id: str, data: str | dict[str, Any], + generated_by: str | None, ) -> InferenceMessage: if not isinstance(data, dict): msg = f"Unknown GeneratedTokenResult: {data}" @@ -227,6 +235,7 @@ def _parse_generated_token_result( request_id=request_id, kind=InferenceMessageKind.DONE, summary=GenerationSummary.from_dict(data["Done"]), + generated_by=generated_by, ) if "ToolCallParsed" in data: @@ -242,6 +251,7 @@ def _parse_generated_token_result( request_id=request_id, kind=InferenceMessageKind.TOOL_CALL_PARSED, parsed_tool_calls=parsed_calls, + generated_by=generated_by, ) if "ToolCallParseFailed" in data: @@ -249,6 +259,7 @@ def _parse_generated_token_result( request_id=request_id, kind=InferenceMessageKind.TOOL_CALL_PARSE_FAILED, error_message=str(data["ToolCallParseFailed"]), + generated_by=generated_by, ) if "ToolCallValidationFailed" in data: @@ -262,6 +273,7 @@ def _parse_generated_token_result( request_id=request_id, kind=InferenceMessageKind.TOOL_CALL_VALIDATION_FAILED, error_message=joined_errors, + generated_by=generated_by, ) for key, kind in _GENERATED_TOKEN_KINDS.items(): @@ -270,6 +282,7 @@ def _parse_generated_token_result( request_id=request_id, kind=kind, token=data[key], + generated_by=generated_by, ) for key, kind in _GENERATED_TOKEN_ERROR_KINDS.items(): @@ -278,6 +291,7 @@ def _parse_generated_token_result( request_id=request_id, kind=kind, error_message=data[key], + generated_by=generated_by, ) msg = f"Unknown GeneratedTokenResult: {data}" @@ -287,11 +301,13 @@ def _parse_generated_token_result( def _parse_embedding_result( request_id: str, data: str | dict[str, Any], + generated_by: str | None, ) -> InferenceMessage: if data == "Done": return InferenceMessage( request_id=request_id, kind=InferenceMessageKind.EMBEDDING_DONE, + generated_by=generated_by, ) if isinstance(data, dict): @@ -302,6 +318,7 @@ def _parse_embedding_result( request_id=request_id, kind=InferenceMessageKind.EMBEDDING, embedding_data=embedding, + generated_by=generated_by, ) if "Error" in data: @@ -309,6 +326,7 @@ def _parse_embedding_result( request_id=request_id, kind=InferenceMessageKind.EMBEDDING_ERROR, error_message=data["Error"], + generated_by=generated_by, ) msg = f"Unknown EmbeddingResult: {data}" diff --git a/paddler_tests/src/collect_embedding_results.rs b/paddler_tests/src/collect_embedding_results.rs index 08bbc0fe..69ecd373 100644 --- a/paddler_tests/src/collect_embedding_results.rs +++ b/paddler_tests/src/collect_embedding_results.rs @@ -2,18 +2,18 @@ use anyhow::Context as _; use anyhow::Result; use anyhow::anyhow; use futures_util::StreamExt as _; -use paddler_types::embedding::Embedding; use paddler_types::embedding_result::EmbeddingResult; use paddler_types::inference_client::Message as InferenceMessage; use paddler_types::inference_client::Response as InferenceResponse; use crate::collected_embedding_results::CollectedEmbeddingResults; +use crate::embedding_with_producer::EmbeddingWithProducer; use crate::inference_message_stream::InferenceMessageStream; pub async fn collect_embedding_results( mut stream: InferenceMessageStream, ) -> Result { - let mut embeddings: Vec = Vec::new(); + let mut embeddings: Vec = Vec::new(); let mut errors: Vec = Vec::new(); let mut saw_done = false; @@ -21,32 +21,39 @@ pub async fn collect_embedding_results( let message = item.context("embedding stream yielded an error")?; match message { - InferenceMessage::Response(envelope) => match envelope.response { - InferenceResponse::Embedding(EmbeddingResult::Done) => { - saw_done = true; + InferenceMessage::Response(envelope) => { + let generated_by = envelope.generated_by.clone(); - break; - } - InferenceResponse::Embedding(EmbeddingResult::Embedding(embedding)) => { - embeddings.push(embedding); - } - InferenceResponse::Embedding(EmbeddingResult::Error(message)) => { - errors.push(message); - } - InferenceResponse::GeneratedToken(_) => { - return Err(anyhow!( - "unexpected generated-token response on an embedding stream" - )); - } - InferenceResponse::Timeout => { - return Err(anyhow!("embedding request timed out on balancer")); - } - InferenceResponse::TooManyBufferedRequests => { - return Err(anyhow!( - "balancer rejected embedding request: too many buffered" - )); + match envelope.response { + InferenceResponse::Embedding(EmbeddingResult::Done) => { + saw_done = true; + + break; + } + InferenceResponse::Embedding(EmbeddingResult::Embedding(embedding)) => { + embeddings.push(EmbeddingWithProducer { + embedding, + generated_by, + }); + } + InferenceResponse::Embedding(EmbeddingResult::Error(message)) => { + errors.push(message); + } + InferenceResponse::GeneratedToken(_) => { + return Err(anyhow!( + "unexpected generated-token response on an embedding stream" + )); + } + InferenceResponse::Timeout => { + return Err(anyhow!("embedding request timed out on balancer")); + } + InferenceResponse::TooManyBufferedRequests => { + return Err(anyhow!( + "balancer rejected embedding request: too many buffered" + )); + } } - }, + } InferenceMessage::Error(error_envelope) => { return Err(anyhow!( "embedding stream returned JSON-RPC error code {} ({})", diff --git a/paddler_tests/src/collect_generated_tokens.rs b/paddler_tests/src/collect_generated_tokens.rs index a2f98964..baddceea 100644 --- a/paddler_tests/src/collect_generated_tokens.rs +++ b/paddler_tests/src/collect_generated_tokens.rs @@ -2,50 +2,57 @@ use anyhow::Context as _; use anyhow::Result; use anyhow::anyhow; use futures_util::StreamExt as _; -use paddler_types::generated_token_result::GeneratedTokenResult; use paddler_types::inference_client::Message as InferenceMessage; use paddler_types::inference_client::Response as InferenceResponse; use paddler_types::streamable_result::StreamableResult as _; use crate::collected_generated_tokens::CollectedGeneratedTokens; use crate::inference_message_stream::InferenceMessageStream; +use crate::token_result_with_producer::TokenResultWithProducer; pub async fn collect_generated_tokens( mut stream: InferenceMessageStream, ) -> Result { let mut text = String::new(); - let mut token_results: Vec = Vec::new(); + let mut token_results: Vec = Vec::new(); while let Some(item) = stream.next().await { let message = item.context("inference stream yielded an error")?; match message { - InferenceMessage::Response(envelope) => match envelope.response { - InferenceResponse::GeneratedToken(token_result) => { - if let Some(token_text) = token_result.token_text() { - text.push_str(token_text); - } + InferenceMessage::Response(envelope) => { + let generated_by = envelope.generated_by.clone(); + + match envelope.response { + InferenceResponse::GeneratedToken(token_result) => { + if let Some(token_text) = token_result.token_text() { + text.push_str(token_text); + } - let is_done = token_result.is_done(); + let is_done = token_result.is_done(); - token_results.push(token_result); + token_results.push(TokenResultWithProducer { + token_result, + generated_by, + }); - if is_done { - break; + if is_done { + break; + } + } + InferenceResponse::Embedding(_) => { + return Err(anyhow!( + "unexpected embedding response on a token-generation stream" + )); + } + InferenceResponse::Timeout => { + return Err(anyhow!("inference request timed out on balancer")); + } + InferenceResponse::TooManyBufferedRequests => { + return Err(anyhow!("balancer rejected request: too many buffered")); } } - InferenceResponse::Embedding(_) => { - return Err(anyhow!( - "unexpected embedding response on a token-generation stream" - )); - } - InferenceResponse::Timeout => { - return Err(anyhow!("inference request timed out on balancer")); - } - InferenceResponse::TooManyBufferedRequests => { - return Err(anyhow!("balancer rejected request: too many buffered")); - } - }, + } InferenceMessage::Error(error_envelope) => { return Err(anyhow!( "inference stream returned JSON-RPC error code {} ({})", diff --git a/paddler_tests/src/collected_embedding_results.rs b/paddler_tests/src/collected_embedding_results.rs index 15aecdac..757462b2 100644 --- a/paddler_tests/src/collected_embedding_results.rs +++ b/paddler_tests/src/collected_embedding_results.rs @@ -1,7 +1,7 @@ -use paddler_types::embedding::Embedding; +use crate::embedding_with_producer::EmbeddingWithProducer; pub struct CollectedEmbeddingResults { - pub embeddings: Vec, + pub embeddings: Vec, pub errors: Vec, pub saw_done: bool, } diff --git a/paddler_tests/src/collected_generated_tokens.rs b/paddler_tests/src/collected_generated_tokens.rs index d725fe64..59779702 100644 --- a/paddler_tests/src/collected_generated_tokens.rs +++ b/paddler_tests/src/collected_generated_tokens.rs @@ -1,6 +1,6 @@ -use paddler_types::generated_token_result::GeneratedTokenResult; +use crate::token_result_with_producer::TokenResultWithProducer; pub struct CollectedGeneratedTokens { pub text: String, - pub token_results: Vec, + pub token_results: Vec, } diff --git a/paddler_tests/src/embedding_with_producer.rs b/paddler_tests/src/embedding_with_producer.rs new file mode 100644 index 00000000..cc3bf3d6 --- /dev/null +++ b/paddler_tests/src/embedding_with_producer.rs @@ -0,0 +1,7 @@ +use paddler_types::embedding::Embedding; + +#[derive(Debug)] +pub struct EmbeddingWithProducer { + pub embedding: Embedding, + pub generated_by: Option, +} diff --git a/paddler_tests/src/lib.rs b/paddler_tests/src/lib.rs index ec29a9dd..beb2cfc5 100644 --- a/paddler_tests/src/lib.rs +++ b/paddler_tests/src/lib.rs @@ -11,6 +11,7 @@ pub mod collect_generated_tokens; pub mod collected_embedding_results; pub mod collected_generated_tokens; pub mod current_test_device; +pub mod embedding_with_producer; pub mod in_process_cluster_params; pub mod inference_http_client; pub mod inference_message_stream; @@ -45,4 +46,5 @@ pub mod state_database_file; pub mod subprocess_cluster_params; pub mod terminate_child; pub mod test_device; +pub mod token_result_with_producer; pub mod wait_until_healthy; diff --git a/paddler_tests/src/token_result_with_producer.rs b/paddler_tests/src/token_result_with_producer.rs new file mode 100644 index 00000000..6687eb4a --- /dev/null +++ b/paddler_tests/src/token_result_with_producer.rs @@ -0,0 +1,7 @@ +use paddler_types::generated_token_result::GeneratedTokenResult; + +#[derive(Debug)] +pub struct TokenResultWithProducer { + pub token_result: GeneratedTokenResult, + pub generated_by: Option, +} diff --git a/paddler_tests/tests/agent_chunks_embedding_batch_larger_than_slot_count.rs b/paddler_tests/tests/agent_chunks_embedding_batch_larger_than_slot_count.rs index 75aec4ef..be50829a 100644 --- a/paddler_tests/tests/agent_chunks_embedding_batch_larger_than_slot_count.rs +++ b/paddler_tests/tests/agent_chunks_embedding_batch_larger_than_slot_count.rs @@ -50,16 +50,16 @@ async fn agent_chunks_embedding_batch_larger_than_slot_count() -> Result<()> { let returned_ids: BTreeSet = collected .embeddings .iter() - .map(|embedding| embedding.source_document_id.clone()) + .map(|produced| produced.embedding.source_document_id.clone()) .collect(); let expected_ids: BTreeSet = (0..12).map(|index| format!("doc-{index}")).collect(); assert_eq!(returned_ids, expected_ids); - let first_dimension = collected.embeddings[0].embedding.len(); + let first_dimension = collected.embeddings[0].embedding.embedding.len(); - for embedding in &collected.embeddings { - assert_eq!(embedding.embedding.len(), first_dimension); + for produced in &collected.embeddings { + assert_eq!(produced.embedding.embedding.len(), first_dimension); } cluster.shutdown().await?; diff --git a/paddler_tests/tests/agent_controller_pool_distributes_concurrent_dispatch_evenly_across_idle_agents.rs b/paddler_tests/tests/agent_controller_pool_distributes_concurrent_dispatch_evenly_across_idle_agents.rs new file mode 100644 index 00000000..e9609871 --- /dev/null +++ b/paddler_tests/tests/agent_controller_pool_distributes_concurrent_dispatch_evenly_across_idle_agents.rs @@ -0,0 +1,65 @@ +use std::sync::Arc; + +use anyhow::Result; +use paddler::balancer::agent_controller_pool::AgentControllerPool; +use paddler_tests::make_agent_controller_without_remote_agent::make_agent_controller_without_remote_agent; +use tokio::sync::Barrier; + +#[tokio::test(flavor = "multi_thread", worker_threads = 8)] +async fn agent_controller_pool_distributes_concurrent_dispatch_evenly_across_idle_agents() +-> Result<()> { + const AGENT_COUNT: usize = 4; + const SLOTS_PER_AGENT: i32 = 4; + const PARALLEL_CALLERS: usize = AGENT_COUNT * (SLOTS_PER_AGENT as usize); + + let pool = Arc::new(AgentControllerPool::default()); + let mut controllers = Vec::with_capacity(AGENT_COUNT); + + for index in 0..AGENT_COUNT { + let agent_id = format!("agent-{index}"); + let controller = Arc::new(make_agent_controller_without_remote_agent(&agent_id)); + + controller.slots_total.set(SLOTS_PER_AGENT); + pool.register_agent_controller(agent_id, controller.clone())?; + controllers.push(controller); + } + + let barrier = Arc::new(Barrier::new(PARALLEL_CALLERS)); + let mut handles = Vec::with_capacity(PARALLEL_CALLERS); + + for _ in 0..PARALLEL_CALLERS { + let pool_for_task = pool.clone(); + let barrier_for_task = barrier.clone(); + + handles.push(tokio::spawn(async move { + barrier_for_task.wait().await; + + pool_for_task.take_least_busy_agent_controller() + })); + } + + let mut acquired = Vec::with_capacity(PARALLEL_CALLERS); + + for handle in handles { + if let Some(dispatched_agent) = handle.await? { + acquired.push(dispatched_agent); + } + } + + assert_eq!( + acquired.len(), + PARALLEL_CALLERS, + "every caller must acquire a slot when total capacity equals concurrency" + ); + + for controller in &controllers { + assert_eq!( + controller.slots_processing.get(), + SLOTS_PER_AGENT, + "agent {} should be filled to capacity under fair burst dispatch", + controller.id, + ); + } + + Ok(()) +} diff --git a/paddler_tests/tests/agent_conversation_accepts_empty_tools_list.rs b/paddler_tests/tests/agent_conversation_accepts_empty_tools_list.rs index 7933b221..7ab70d80 100644 --- a/paddler_tests/tests/agent_conversation_accepts_empty_tools_list.rs +++ b/paddler_tests/tests/agent_conversation_accepts_empty_tools_list.rs @@ -41,7 +41,7 @@ async fn agent_conversation_accepts_empty_tools_list() -> Result<()> { let token_count = collected .token_results .iter() - .filter(|result| result.is_token()) + .filter(|result| result.token_result.is_token()) .count(); assert!(token_count > 0); diff --git a/paddler_tests/tests/agent_conversation_history_respects_max_tokens.rs b/paddler_tests/tests/agent_conversation_history_respects_max_tokens.rs index 195f3eb1..f663d22a 100644 --- a/paddler_tests/tests/agent_conversation_history_respects_max_tokens.rs +++ b/paddler_tests/tests/agent_conversation_history_respects_max_tokens.rs @@ -41,7 +41,7 @@ async fn agent_conversation_history_respects_max_tokens() -> Result<()> { let token_count = collected .token_results .iter() - .filter(|result| result.is_token()) + .filter(|result| result.token_result.is_token()) .count(); assert!(token_count > 0); diff --git a/paddler_tests/tests/agent_embedding_batch_distribution_independent_of_context_size.rs b/paddler_tests/tests/agent_embedding_batch_distribution_independent_of_context_size.rs index d559046e..468c70b9 100644 --- a/paddler_tests/tests/agent_embedding_batch_distribution_independent_of_context_size.rs +++ b/paddler_tests/tests/agent_embedding_batch_distribution_independent_of_context_size.rs @@ -62,7 +62,7 @@ async fn agent_embedding_batch_distribution_independent_of_context_size() -> Res let returned_ids: BTreeSet = collected .embeddings .iter() - .map(|embedding| embedding.source_document_id.clone()) + .map(|produced| produced.embedding.source_document_id.clone()) .collect(); let expected_ids: BTreeSet = BTreeSet::from([ diff --git a/paddler_tests/tests/agent_embedding_batch_returns_one_embedding_per_input_document.rs b/paddler_tests/tests/agent_embedding_batch_returns_one_embedding_per_input_document.rs index 59a8e670..76fec83a 100644 --- a/paddler_tests/tests/agent_embedding_batch_returns_one_embedding_per_input_document.rs +++ b/paddler_tests/tests/agent_embedding_batch_returns_one_embedding_per_input_document.rs @@ -52,7 +52,7 @@ async fn agent_embedding_batch_returns_one_embedding_per_input_document() -> Res let returned_ids: BTreeSet = collected .embeddings .iter() - .map(|embedding| embedding.source_document_id.clone()) + .map(|produced| produced.embedding.source_document_id.clone()) .collect(); let expected_ids: BTreeSet = diff --git a/paddler_tests/tests/agent_embeddings_share_dimension_across_inputs_of_varying_length.rs b/paddler_tests/tests/agent_embeddings_share_dimension_across_inputs_of_varying_length.rs index 713d77a1..c6c1d2d6 100644 --- a/paddler_tests/tests/agent_embeddings_share_dimension_across_inputs_of_varying_length.rs +++ b/paddler_tests/tests/agent_embeddings_share_dimension_across_inputs_of_varying_length.rs @@ -50,17 +50,17 @@ async fn agent_embeddings_share_dimension_across_inputs_of_varying_length() -> R assert_eq!(collected.embeddings.len(), 3); assert!(collected.saw_done); - let first_dimension = collected.embeddings[0].embedding.len(); + let first_dimension = collected.embeddings[0].embedding.embedding.len(); assert!(first_dimension > 0, "embedding dimension must be positive"); - for embedding in &collected.embeddings { + for produced in &collected.embeddings { assert_eq!( - embedding.embedding.len(), + produced.embedding.embedding.len(), first_dimension, "all embeddings must share dimension; {} has {} instead of {}", - embedding.source_document_id, - embedding.embedding.len(), + produced.embedding.source_document_id, + produced.embedding.embedding.len(), first_dimension ); } diff --git a/paddler_tests/tests/agent_grammar_with_thinking_returns_incompatible_error.rs b/paddler_tests/tests/agent_grammar_with_thinking_returns_incompatible_error.rs index c1a023b6..691bd392 100644 --- a/paddler_tests/tests/agent_grammar_with_thinking_returns_incompatible_error.rs +++ b/paddler_tests/tests/agent_grammar_with_thinking_returns_incompatible_error.rs @@ -46,7 +46,7 @@ async fn agent_grammar_with_thinking_returns_incompatible_error() -> Result<()> if let Ok(collected) = collected { assert!( collected.token_results.iter().any(|result| matches!( - result, + result.token_result, GeneratedTokenResult::GrammarIncompatibleWithThinking(_) )), "expected GrammarIncompatibleWithThinking error" diff --git a/paddler_tests/tests/agent_isolates_concurrent_embedding_requests_per_client.rs b/paddler_tests/tests/agent_isolates_concurrent_embedding_requests_per_client.rs index 7156eb9a..f274995b 100644 --- a/paddler_tests/tests/agent_isolates_concurrent_embedding_requests_per_client.rs +++ b/paddler_tests/tests/agent_isolates_concurrent_embedding_requests_per_client.rs @@ -71,7 +71,7 @@ async fn agent_isolates_concurrent_embedding_requests_per_client() -> Result<()> let returned_ids: BTreeSet = collected .embeddings .iter() - .map(|embedding| embedding.source_document_id.clone()) + .map(|produced| produced.embedding.source_document_id.clone()) .collect(); let expected_ids: BTreeSet = (0..docs_per_client) .map(|document_index| format!("client-{client_index}-doc-{document_index}")) diff --git a/paddler_tests/tests/agent_l2_normalized_embeddings_have_unit_norm.rs b/paddler_tests/tests/agent_l2_normalized_embeddings_have_unit_norm.rs index 1304674b..29047da4 100644 --- a/paddler_tests/tests/agent_l2_normalized_embeddings_have_unit_norm.rs +++ b/paddler_tests/tests/agent_l2_normalized_embeddings_have_unit_norm.rs @@ -40,14 +40,15 @@ async fn agent_l2_normalized_embeddings_have_unit_norm() -> Result<()> { assert_eq!(collected.embeddings.len(), 1); assert!(collected.saw_done); - let embedding = &collected.embeddings[0]; + let produced = &collected.embeddings[0]; assert!(matches!( - embedding.normalization_method, + produced.embedding.normalization_method, EmbeddingNormalizationMethod::L2 )); - let l2_norm: f32 = embedding + let l2_norm: f32 = produced + .embedding .embedding .iter() .map(|value| value * value) diff --git a/paddler_tests/tests/agent_raw_prompt_respects_max_tokens.rs b/paddler_tests/tests/agent_raw_prompt_respects_max_tokens.rs index d781b122..714aaa43 100644 --- a/paddler_tests/tests/agent_raw_prompt_respects_max_tokens.rs +++ b/paddler_tests/tests/agent_raw_prompt_respects_max_tokens.rs @@ -31,7 +31,7 @@ async fn agent_raw_prompt_respects_max_tokens() -> Result<()> { let token_count = collected .token_results .iter() - .filter(|result| result.is_token()) + .filter(|result| result.token_result.is_token()) .count(); assert!(token_count > 0); diff --git a/paddler_tests/tests/agent_returns_identical_embeddings_for_identical_documents.rs b/paddler_tests/tests/agent_returns_identical_embeddings_for_identical_documents.rs index 7f3dc1b8..3a7ca664 100644 --- a/paddler_tests/tests/agent_returns_identical_embeddings_for_identical_documents.rs +++ b/paddler_tests/tests/agent_returns_identical_embeddings_for_identical_documents.rs @@ -52,17 +52,17 @@ async fn agent_returns_identical_embeddings_for_identical_documents() -> Result< let first = collected .embeddings .iter() - .find(|embedding| embedding.source_document_id == "doc-first") + .find(|produced| produced.embedding.source_document_id == "doc-first") .context("first embedding missing")?; let second = collected .embeddings .iter() - .find(|embedding| embedding.source_document_id == "doc-second") + .find(|produced| produced.embedding.source_document_id == "doc-second") .context("second embedding missing")?; assert_eq!( - first.embedding, second.embedding, + first.embedding.embedding, second.embedding.embedding, "identical documents must produce identical embedding vectors" ); diff --git a/paddler_tests/tests/agent_returns_image_decoding_error_for_invalid_base64.rs b/paddler_tests/tests/agent_returns_image_decoding_error_for_invalid_base64.rs index 7904a753..3fa1f7ae 100644 --- a/paddler_tests/tests/agent_returns_image_decoding_error_for_invalid_base64.rs +++ b/paddler_tests/tests/agent_returns_image_decoding_error_for_invalid_base64.rs @@ -52,10 +52,12 @@ async fn agent_returns_image_decoding_error_for_invalid_base64() -> Result<()> { let collected = collect_generated_tokens(stream).await; if let Ok(collected) = collected { - let saw_decoding_error = collected - .token_results - .iter() - .any(|result| matches!(result, GeneratedTokenResult::ImageDecodingFailed(_))); + let saw_decoding_error = collected.token_results.iter().any(|result| { + matches!( + result.token_result, + GeneratedTokenResult::ImageDecodingFailed(_) + ) + }); assert!( saw_decoding_error, diff --git a/paddler_tests/tests/agent_returns_image_decoding_error_for_malformed_data_uri.rs b/paddler_tests/tests/agent_returns_image_decoding_error_for_malformed_data_uri.rs index 1b133a1a..da1fc8f0 100644 --- a/paddler_tests/tests/agent_returns_image_decoding_error_for_malformed_data_uri.rs +++ b/paddler_tests/tests/agent_returns_image_decoding_error_for_malformed_data_uri.rs @@ -52,10 +52,12 @@ async fn agent_returns_image_decoding_error_for_malformed_data_uri() -> Result<( let collected = collect_generated_tokens(stream).await; if let Ok(collected) = collected { - let saw_decoding_error = collected - .token_results - .iter() - .any(|result| matches!(result, GeneratedTokenResult::ImageDecodingFailed(_))); + let saw_decoding_error = collected.token_results.iter().any(|result| { + matches!( + result.token_result, + GeneratedTokenResult::ImageDecodingFailed(_) + ) + }); assert!( saw_decoding_error, diff --git a/paddler_tests/tests/agent_returns_image_decoding_error_for_remote_url.rs b/paddler_tests/tests/agent_returns_image_decoding_error_for_remote_url.rs index e2ef1010..4e00cddc 100644 --- a/paddler_tests/tests/agent_returns_image_decoding_error_for_remote_url.rs +++ b/paddler_tests/tests/agent_returns_image_decoding_error_for_remote_url.rs @@ -52,10 +52,12 @@ async fn agent_returns_image_decoding_error_for_remote_url() -> Result<()> { let collected = collect_generated_tokens(stream).await; if let Ok(collected) = collected { - let saw_decoding_error = collected - .token_results - .iter() - .any(|result| matches!(result, GeneratedTokenResult::ImageDecodingFailed(_))); + let saw_decoding_error = collected.token_results.iter().any(|result| { + matches!( + result.token_result, + GeneratedTokenResult::ImageDecodingFailed(_) + ) + }); assert!( saw_decoding_error, diff --git a/paddler_tests/tests/agent_returns_rms_normalized_embeddings_when_requested.rs b/paddler_tests/tests/agent_returns_rms_normalized_embeddings_when_requested.rs index 7d090b4f..cd33a648 100644 --- a/paddler_tests/tests/agent_returns_rms_normalized_embeddings_when_requested.rs +++ b/paddler_tests/tests/agent_returns_rms_normalized_embeddings_when_requested.rs @@ -40,7 +40,7 @@ async fn agent_returns_rms_normalized_embeddings_when_requested() -> Result<()> assert_eq!(collected.embeddings.len(), 1); assert!(collected.saw_done); assert!(matches!( - collected.embeddings[0].normalization_method, + collected.embeddings[0].embedding.normalization_method, EmbeddingNormalizationMethod::RmsNorm { .. } )); diff --git a/paddler_tests/tests/agent_returns_unnormalized_embeddings_when_requested.rs b/paddler_tests/tests/agent_returns_unnormalized_embeddings_when_requested.rs index 5dad152f..a135d9d0 100644 --- a/paddler_tests/tests/agent_returns_unnormalized_embeddings_when_requested.rs +++ b/paddler_tests/tests/agent_returns_unnormalized_embeddings_when_requested.rs @@ -40,7 +40,7 @@ async fn agent_returns_unnormalized_embeddings_when_requested() -> Result<()> { assert_eq!(collected.embeddings.len(), 1); assert!(collected.saw_done); assert!(matches!( - collected.embeddings[0].normalization_method, + collected.embeddings[0].embedding.normalization_method, EmbeddingNormalizationMethod::None )); diff --git a/paddler_tests/tests/agent_serves_four_concurrent_clients_streaming_tokens.rs b/paddler_tests/tests/agent_serves_four_concurrent_clients_streaming_tokens.rs index 04f43693..b9b1ebac 100644 --- a/paddler_tests/tests/agent_serves_four_concurrent_clients_streaming_tokens.rs +++ b/paddler_tests/tests/agent_serves_four_concurrent_clients_streaming_tokens.rs @@ -45,7 +45,7 @@ async fn agent_serves_four_concurrent_clients_streaming_tokens() -> Result<()> { let token_count = collected .token_results .iter() - .filter(|result| result.is_token()) + .filter(|result| result.token_result.is_token()) .count(); assert!(token_count > 0); diff --git a/paddler_tests/tests/agent_streams_tokens_from_conversation_history_over_http.rs b/paddler_tests/tests/agent_streams_tokens_from_conversation_history_over_http.rs index dd897dc1..b423243d 100644 --- a/paddler_tests/tests/agent_streams_tokens_from_conversation_history_over_http.rs +++ b/paddler_tests/tests/agent_streams_tokens_from_conversation_history_over_http.rs @@ -41,7 +41,7 @@ async fn agent_streams_tokens_from_conversation_history_over_http() -> Result<() let token_count = collected .token_results .iter() - .filter(|result| result.is_token()) + .filter(|result| result.token_result.is_token()) .count(); assert!(token_count > 0); diff --git a/paddler_tests/tests/agent_streams_tokens_from_image_data_uri.rs b/paddler_tests/tests/agent_streams_tokens_from_image_data_uri.rs index ce14ff19..a57943b9 100644 --- a/paddler_tests/tests/agent_streams_tokens_from_image_data_uri.rs +++ b/paddler_tests/tests/agent_streams_tokens_from_image_data_uri.rs @@ -12,7 +12,6 @@ use paddler_types::conversation_history::ConversationHistory; use paddler_types::conversation_message::ConversationMessage; use paddler_types::conversation_message_content::ConversationMessageContent; use paddler_types::conversation_message_content_part::ConversationMessageContentPart; -use paddler_types::generated_token_result::GeneratedTokenResult; use paddler_types::image_url::ImageUrl; use paddler_types::request_params::continue_from_conversation_history_params::ContinueFromConversationHistoryParams; use reqwest::Client; @@ -56,7 +55,7 @@ async fn agent_streams_tokens_from_image_data_uri() -> Result<()> { let received_tokens = collected .token_results .iter() - .any(GeneratedTokenResult::is_token); + .any(|result| result.token_result.is_token()); assert!(received_tokens); diff --git a/paddler_tests/tests/agent_streams_tokens_from_raw_prompt.rs b/paddler_tests/tests/agent_streams_tokens_from_raw_prompt.rs index 6054094d..a1049a6a 100644 --- a/paddler_tests/tests/agent_streams_tokens_from_raw_prompt.rs +++ b/paddler_tests/tests/agent_streams_tokens_from_raw_prompt.rs @@ -31,7 +31,7 @@ async fn agent_streams_tokens_from_raw_prompt() -> Result<()> { let token_count = collected .token_results .iter() - .filter(|result| result.is_token()) + .filter(|result| result.token_result.is_token()) .count(); assert!(token_count > 0); diff --git a/paddler_tests/tests/agent_text_only_model_rejects_image_input.rs b/paddler_tests/tests/agent_text_only_model_rejects_image_input.rs index e2970c8a..2172a1ee 100644 --- a/paddler_tests/tests/agent_text_only_model_rejects_image_input.rs +++ b/paddler_tests/tests/agent_text_only_model_rejects_image_input.rs @@ -57,7 +57,7 @@ async fn agent_text_only_model_rejects_image_input() -> Result<()> { if let Ok(collected) = collected { let saw_rejection = collected.token_results.iter().any(|result| { matches!( - result, + result.token_result, GeneratedTokenResult::ChatTemplateError(_) | GeneratedTokenResult::MultimodalNotSupported(_) ) diff --git a/paddler_tests/tests/balancer_distributes_embedding_batch_across_agents.rs b/paddler_tests/tests/balancer_distributes_embedding_batch_across_agents.rs index 802f9b2d..58fcdd32 100644 --- a/paddler_tests/tests/balancer_distributes_embedding_batch_across_agents.rs +++ b/paddler_tests/tests/balancer_distributes_embedding_batch_across_agents.rs @@ -18,7 +18,7 @@ use reqwest::Client; #[serial_test::file_serial(model_load, path => "../target/model_load.lock")] #[tokio::test(flavor = "multi_thread")] async fn balancer_distributes_embedding_batch_across_agents() -> Result<()> { - let mut cluster = start_subprocess_cluster_with_qwen3_embedding( + let cluster = start_subprocess_cluster_with_qwen3_embedding( InferenceParameters { enable_embeddings: true, ..InferenceParameters::default() @@ -43,44 +43,24 @@ async fn balancer_distributes_embedding_batch_across_agents() -> Result<()> { normalization_method: EmbeddingNormalizationMethod::None, }; - let mut seen_busy_agents: BTreeSet = BTreeSet::new(); - let mut have_seen_any_activity = false; - - let request_future = async { - let stream = inference_client - .post_generate_embedding_batch(¶ms) - .await?; - collect_embedding_results(stream).await - }; - - let observation_future = cluster.agents.until(|snapshot| { - let any_busy_now = snapshot - .agents - .iter() - .any(|agent| agent.slots_processing > 0); - - if any_busy_now { - have_seen_any_activity = true; - for agent in &snapshot.agents { - if agent.slots_processing > 0 { - seen_busy_agents.insert(agent.id.clone()); - } - } - } - - seen_busy_agents.len() >= 2 || (have_seen_any_activity && !any_busy_now) - }); - - let (request_result, observation_result) = tokio::join!(request_future, observation_future); - let collected = request_result?; - observation_result?; + let stream = inference_client + .post_generate_embedding_batch(¶ms) + .await?; + let collected = collect_embedding_results(stream).await?; assert_eq!(collected.embeddings.len(), 12); assert!(collected.saw_done); assert!(collected.errors.is_empty()); + + let producers: BTreeSet<&str> = collected + .embeddings + .iter() + .filter_map(|produced| produced.generated_by.as_deref()) + .collect(); + assert!( - seen_busy_agents.len() >= 2, - "expected the embedding batch to be distributed across at least two agents, but only saw activity on: {seen_busy_agents:?}" + producers.len() >= 2, + "expected the embedding batch to be distributed across at least two agents, but only saw producers: {producers:?}" ); cluster.shutdown().await?; diff --git a/paddler_tests/tests/balancer_distributes_embedding_burst_evenly_across_agents.rs b/paddler_tests/tests/balancer_distributes_embedding_burst_evenly_across_agents.rs new file mode 100644 index 00000000..3ce9c16c --- /dev/null +++ b/paddler_tests/tests/balancer_distributes_embedding_burst_evenly_across_agents.rs @@ -0,0 +1,86 @@ +#![cfg(all( + feature = "tests_that_use_compiled_paddler", + feature = "tests_that_use_llms" +))] + +use std::collections::BTreeSet; + +use anyhow::Result; +use futures_util::future; +use paddler_tests::collect_embedding_results::collect_embedding_results; +use paddler_tests::inference_http_client::InferenceHttpClient; +use paddler_tests::start_subprocess_cluster_with_qwen3_embedding::start_subprocess_cluster_with_qwen3_embedding; +use paddler_types::embedding_input_document::EmbeddingInputDocument; +use paddler_types::embedding_normalization_method::EmbeddingNormalizationMethod; +use paddler_types::inference_parameters::InferenceParameters; +use paddler_types::request_params::GenerateEmbeddingBatchParams; +use reqwest::Client; + +#[serial_test::file_serial(model_load, path => "../target/model_load.lock")] +#[tokio::test(flavor = "multi_thread")] +async fn balancer_distributes_embedding_burst_evenly_across_agents() -> Result<()> { + const AGENT_COUNT: usize = 4; + const SLOTS_PER_AGENT: i32 = 2; + const CONCURRENT_REQUESTS: usize = 8; + + let cluster = start_subprocess_cluster_with_qwen3_embedding( + InferenceParameters { + enable_embeddings: true, + ..InferenceParameters::default() + }, + SLOTS_PER_AGENT, + AGENT_COUNT, + ) + .await?; + + let inference_client = + InferenceHttpClient::new(Client::new(), cluster.addresses.inference_base_url()?); + + let collection_futures = (0..CONCURRENT_REQUESTS).map(|request_index| { + let inference_client = inference_client.clone(); + async move { + let input_batch: Vec = (0..4) + .map(|document_index| EmbeddingInputDocument { + content: format!( + "Burst request {request_index}, document {document_index}: \ + provide an embedding for evaluation." + ), + id: format!("req-{request_index}-doc-{document_index}"), + }) + .collect(); + + let stream = inference_client + .post_generate_embedding_batch(&GenerateEmbeddingBatchParams { + input_batch, + normalization_method: EmbeddingNormalizationMethod::None, + }) + .await?; + + collect_embedding_results(stream).await + } + }); + + let collected_streams = future::try_join_all(collection_futures).await?; + + let producers_across_streams: BTreeSet<&str> = collected_streams + .iter() + .flat_map(|collected| collected.embeddings.iter()) + .filter_map(|produced| produced.generated_by.as_deref()) + .collect(); + + assert_eq!( + producers_across_streams.len(), + AGENT_COUNT, + "burst of {CONCURRENT_REQUESTS} embedding batches across {AGENT_COUNT} agents must reach every agent, but saw producers: {producers_across_streams:?}", + ); + + for collected in &collected_streams { + assert!(collected.saw_done); + assert!(collected.errors.is_empty()); + assert_eq!(collected.embeddings.len(), 4); + } + + cluster.shutdown().await?; + + Ok(()) +} diff --git a/paddler_tests/tests/balancer_distributes_token_burst_evenly_across_agents.rs b/paddler_tests/tests/balancer_distributes_token_burst_evenly_across_agents.rs new file mode 100644 index 00000000..7e466db5 --- /dev/null +++ b/paddler_tests/tests/balancer_distributes_token_burst_evenly_across_agents.rs @@ -0,0 +1,85 @@ +#![cfg(all( + feature = "tests_that_use_compiled_paddler", + feature = "tests_that_use_llms" +))] + +use std::collections::BTreeSet; + +use anyhow::Result; +use anyhow::anyhow; +use futures_util::future; +use paddler_tests::collect_generated_tokens::collect_generated_tokens; +use paddler_tests::inference_http_client::InferenceHttpClient; +use paddler_tests::start_subprocess_cluster_with_qwen3::start_subprocess_cluster_with_qwen3; +use paddler_types::request_params::ContinueFromRawPromptParams; +use reqwest::Client; + +#[serial_test::file_serial(model_load, path => "../target/model_load.lock")] +#[tokio::test(flavor = "multi_thread")] +async fn balancer_distributes_token_burst_evenly_across_agents() -> Result<()> { + const AGENT_COUNT: usize = 4; + const SLOTS_PER_AGENT: i32 = 1; + + let cluster = start_subprocess_cluster_with_qwen3(SLOTS_PER_AGENT, AGENT_COUNT).await?; + + let inference_client = + InferenceHttpClient::new(Client::new(), cluster.addresses.inference_base_url()?); + + let prompts: Vec = (0..AGENT_COUNT) + .map(|index| format!("Burst request number {index}: Count from one to five.")) + .collect(); + + let collection_futures = prompts.iter().map(|prompt| { + let inference_client = inference_client.clone(); + let raw_prompt = prompt.clone(); + async move { + let stream = inference_client + .post_continue_from_raw_prompt(&ContinueFromRawPromptParams { + grammar: None, + max_tokens: 16, + raw_prompt, + }) + .await?; + + collect_generated_tokens(stream).await + } + }); + + let collected_streams = future::try_join_all(collection_futures).await?; + + let mut producer_per_stream: Vec = Vec::with_capacity(AGENT_COUNT); + + for (stream_index, collected) in collected_streams.iter().enumerate() { + let producers_for_stream: BTreeSet<&str> = collected + .token_results + .iter() + .filter_map(|chunk| chunk.generated_by.as_deref()) + .collect(); + + assert_eq!( + producers_for_stream.len(), + 1, + "stream {stream_index} must be served by exactly one agent, but saw producers: {producers_for_stream:?}", + ); + + let producer = producers_for_stream + .into_iter() + .next() + .ok_or_else(|| anyhow!("stream {stream_index} produced no attributable tokens"))? + .to_owned(); + + producer_per_stream.push(producer); + } + + let unique_producers: BTreeSet<&str> = producer_per_stream.iter().map(String::as_str).collect(); + + assert_eq!( + unique_producers.len(), + AGENT_COUNT, + "burst of {AGENT_COUNT} requests with {SLOTS_PER_AGENT} slot per agent must fan out across all agents, but stream-to-producer map was: {producer_per_stream:?}", + ); + + cluster.shutdown().await?; + + Ok(()) +} diff --git a/paddler_tests/tests/balancer_fans_out_embedding_batch_to_all_agents.rs b/paddler_tests/tests/balancer_fans_out_embedding_batch_to_all_agents.rs index 055fbb56..6da5bdd6 100644 --- a/paddler_tests/tests/balancer_fans_out_embedding_batch_to_all_agents.rs +++ b/paddler_tests/tests/balancer_fans_out_embedding_batch_to_all_agents.rs @@ -20,7 +20,7 @@ use reqwest::Client; async fn balancer_fans_out_embedding_batch_to_all_agents() -> Result<()> { let agent_count: usize = 4; - let mut cluster = start_subprocess_cluster_with_qwen3_embedding( + let cluster = start_subprocess_cluster_with_qwen3_embedding( InferenceParameters { enable_embeddings: true, ..InferenceParameters::default() @@ -45,45 +45,25 @@ async fn balancer_fans_out_embedding_batch_to_all_agents() -> Result<()> { normalization_method: EmbeddingNormalizationMethod::None, }; - let mut seen_busy_agents: BTreeSet = BTreeSet::new(); - let mut have_seen_any_activity = false; - - let request_future = async { - let stream = inference_client - .post_generate_embedding_batch(¶ms) - .await?; - collect_embedding_results(stream).await - }; - - let observation_future = cluster.agents.until(|snapshot| { - let any_busy_now = snapshot - .agents - .iter() - .any(|agent| agent.slots_processing > 0); - - if any_busy_now { - have_seen_any_activity = true; - for agent in &snapshot.agents { - if agent.slots_processing > 0 { - seen_busy_agents.insert(agent.id.clone()); - } - } - } - - seen_busy_agents.len() >= agent_count || (have_seen_any_activity && !any_busy_now) - }); - - let (request_result, observation_result) = tokio::join!(request_future, observation_future); - let collected = request_result?; - observation_result?; + let stream = inference_client + .post_generate_embedding_batch(¶ms) + .await?; + let collected = collect_embedding_results(stream).await?; assert_eq!(collected.embeddings.len(), 16); assert!(collected.saw_done); assert!(collected.errors.is_empty()); + + let producers: BTreeSet<&str> = collected + .embeddings + .iter() + .filter_map(|produced| produced.generated_by.as_deref()) + .collect(); + assert_eq!( - seen_busy_agents.len(), + producers.len(), agent_count, - "expected the embedding batch to fan out across every agent, but only saw activity on: {seen_busy_agents:?}" + "expected the embedding batch to fan out across every agent, but only saw producers: {producers:?}" ); cluster.shutdown().await?; diff --git a/paddler_tests/tests/chat_template_drains_in_flight_inference_before_swap.rs b/paddler_tests/tests/chat_template_drains_in_flight_inference_before_swap.rs index 3d5f7585..ccbf664f 100644 --- a/paddler_tests/tests/chat_template_drains_in_flight_inference_before_swap.rs +++ b/paddler_tests/tests/chat_template_drains_in_flight_inference_before_swap.rs @@ -12,6 +12,7 @@ use paddler_tests::model_card::ModelCard; use paddler_tests::model_card::qwen3_0_6b::qwen3_0_6b; use paddler_tests::start_subprocess_cluster::start_subprocess_cluster; use paddler_tests::subprocess_cluster_params::SubprocessClusterParams; +use paddler_tests::token_result_with_producer::TokenResultWithProducer; use paddler_types::agent_desired_model::AgentDesiredModel; use paddler_types::balancer_desired_state::BalancerDesiredState; use paddler_types::chat_template::ChatTemplate; @@ -101,21 +102,24 @@ async fn chat_template_drains_in_flight_inference_before_swap() -> Result<()> { collected .token_results .iter() - .any(GeneratedTokenResult::is_token), + .any(|result| result.token_result.is_token()), "in-flight request must continue producing tokens during template swap" ); assert!( - !collected - .token_results - .iter() - .any(|result| matches!(result, GeneratedTokenResult::ChatTemplateError(_))), + !collected.token_results.iter().any(|result| matches!( + result.token_result, + GeneratedTokenResult::ChatTemplateError(_) + )), "in-flight request must not see ChatTemplateError during swap" ); assert!(matches!( collected.token_results.last(), - Some(GeneratedTokenResult::Done(_)) + Some(TokenResultWithProducer { + token_result: GeneratedTokenResult::Done(_), + .. + }) )); let retrieved = cluster diff --git a/paddler_tests/tests/chat_template_override_replaces_model_builtin.rs b/paddler_tests/tests/chat_template_override_replaces_model_builtin.rs index 6d6b3302..00b87bdf 100644 --- a/paddler_tests/tests/chat_template_override_replaces_model_builtin.rs +++ b/paddler_tests/tests/chat_template_override_replaces_model_builtin.rs @@ -18,7 +18,6 @@ use paddler_types::chat_template::ChatTemplate; use paddler_types::conversation_history::ConversationHistory; use paddler_types::conversation_message::ConversationMessage; use paddler_types::conversation_message_content::ConversationMessageContent; -use paddler_types::generated_token_result::GeneratedTokenResult; use paddler_types::request_params::continue_from_conversation_history_params::ContinueFromConversationHistoryParams; use reqwest::Client; @@ -92,7 +91,7 @@ async fn chat_template_override_replaces_model_builtin() -> Result<()> { let received_tokens = collected .token_results .iter() - .any(GeneratedTokenResult::is_token); + .any(|result| result.token_result.is_token()); assert!( received_tokens, diff --git a/paddler_tests/tests/chat_template_swaps_between_inference_calls.rs b/paddler_tests/tests/chat_template_swaps_between_inference_calls.rs index 7271c3da..38696ecf 100644 --- a/paddler_tests/tests/chat_template_swaps_between_inference_calls.rs +++ b/paddler_tests/tests/chat_template_swaps_between_inference_calls.rs @@ -18,7 +18,6 @@ use paddler_types::chat_template::ChatTemplate; use paddler_types::conversation_history::ConversationHistory; use paddler_types::conversation_message::ConversationMessage; use paddler_types::conversation_message_content::ConversationMessageContent; -use paddler_types::generated_token_result::GeneratedTokenResult; use paddler_types::request_params::continue_from_conversation_history_params::ContinueFromConversationHistoryParams; use reqwest::Client; @@ -43,7 +42,7 @@ async fn run_inference_after_template_swap(inference_client: &InferenceHttpClien Ok(collected .token_results .iter() - .any(GeneratedTokenResult::is_token)) + .any(|result| result.token_result.is_token())) } #[serial_test::file_serial(model_load, path => "../target/model_load.lock")] diff --git a/paddler_tests/tests/continuous_batch_concurrent_conversation_history_requests_complete.rs b/paddler_tests/tests/continuous_batch_concurrent_conversation_history_requests_complete.rs index 56a65efb..84a57d7e 100644 --- a/paddler_tests/tests/continuous_batch_concurrent_conversation_history_requests_complete.rs +++ b/paddler_tests/tests/continuous_batch_concurrent_conversation_history_requests_complete.rs @@ -4,6 +4,7 @@ use anyhow::Result; use paddler_tests::collect_generated_tokens::collect_generated_tokens; use paddler_tests::inference_http_client::InferenceHttpClient; use paddler_tests::start_in_process_cluster_with_qwen3::start_in_process_cluster_with_qwen3; +use paddler_tests::token_result_with_producer::TokenResultWithProducer; use paddler_types::conversation_history::ConversationHistory; use paddler_types::conversation_message::ConversationMessage; use paddler_types::conversation_message_content::ConversationMessageContent; @@ -61,23 +62,29 @@ async fn continuous_batch_concurrent_conversation_history_requests_complete() -> let tokens_a = collected_a .token_results .iter() - .filter(|result| result.is_token()) + .filter(|result| result.token_result.is_token()) .count(); let tokens_b = collected_b .token_results .iter() - .filter(|result| result.is_token()) + .filter(|result| result.token_result.is_token()) .count(); assert!(tokens_a > 0); assert!(tokens_b > 0); assert!(matches!( collected_a.token_results.last(), - Some(GeneratedTokenResult::Done(_)) + Some(TokenResultWithProducer { + token_result: GeneratedTokenResult::Done(_), + .. + }) )); assert!(matches!( collected_b.token_results.last(), - Some(GeneratedTokenResult::Done(_)) + Some(TokenResultWithProducer { + token_result: GeneratedTokenResult::Done(_), + .. + }) )); cluster.shutdown().await?; diff --git a/paddler_tests/tests/continuous_batch_evicts_long_sequence_under_kv_pressure.rs b/paddler_tests/tests/continuous_batch_evicts_long_sequence_under_kv_pressure.rs index ecd60209..d7fbdb9a 100644 --- a/paddler_tests/tests/continuous_batch_evicts_long_sequence_under_kv_pressure.rs +++ b/paddler_tests/tests/continuous_batch_evicts_long_sequence_under_kv_pressure.rs @@ -8,6 +8,7 @@ use paddler_tests::inference_http_client::InferenceHttpClient; use paddler_tests::model_card::ModelCard; use paddler_tests::model_card::qwen3_0_6b::qwen3_0_6b; use paddler_tests::start_in_process_cluster::start_in_process_cluster; +use paddler_tests::token_result_with_producer::TokenResultWithProducer; use paddler_types::agent_desired_model::AgentDesiredModel; use paddler_types::balancer_desired_state::BalancerDesiredState; use paddler_types::generated_token_result::GeneratedTokenResult; @@ -77,7 +78,7 @@ async fn continuous_batch_evicts_long_sequence_under_kv_pressure() -> Result<()> let short_collected = short_collected?; let long_was_evicted = long_collected.token_results.iter().any(|result| { - matches!(result, GeneratedTokenResult::SamplerError(message) if message.contains("evicted")) + matches!(&result.token_result, GeneratedTokenResult::SamplerError(message) if message.contains("evicted")) }); assert!( @@ -86,7 +87,10 @@ async fn continuous_batch_evicts_long_sequence_under_kv_pressure() -> Result<()> ); assert!(matches!( short_collected.token_results.last(), - Some(GeneratedTokenResult::Done(_)) + Some(TokenResultWithProducer { + token_result: GeneratedTokenResult::Done(_), + .. + }) )); cluster.shutdown().await?; diff --git a/paddler_tests/tests/continuous_batch_generates_tokens_with_distinct_k_and_v_cache_dtypes.rs b/paddler_tests/tests/continuous_batch_generates_tokens_with_distinct_k_and_v_cache_dtypes.rs index bddfb516..041d5f3b 100644 --- a/paddler_tests/tests/continuous_batch_generates_tokens_with_distinct_k_and_v_cache_dtypes.rs +++ b/paddler_tests/tests/continuous_batch_generates_tokens_with_distinct_k_and_v_cache_dtypes.rs @@ -8,6 +8,7 @@ use paddler_tests::inference_http_client::InferenceHttpClient; use paddler_tests::model_card::ModelCard; use paddler_tests::model_card::qwen3_0_6b::qwen3_0_6b; use paddler_tests::start_in_process_cluster::start_in_process_cluster; +use paddler_tests::token_result_with_producer::TokenResultWithProducer; use paddler_types::agent_desired_model::AgentDesiredModel; use paddler_types::balancer_desired_state::BalancerDesiredState; use paddler_types::generated_token_result::GeneratedTokenResult; @@ -63,13 +64,16 @@ async fn continuous_batch_generates_tokens_with_distinct_k_and_v_cache_dtypes() let token_count = collected .token_results .iter() - .filter(|result| result.is_token()) + .filter(|result| result.token_result.is_token()) .count(); assert!(token_count > 0); assert!(matches!( collected.token_results.last(), - Some(GeneratedTokenResult::Done(_)) + Some(TokenResultWithProducer { + token_result: GeneratedTokenResult::Done(_), + .. + }) )); cluster.shutdown().await?; diff --git a/paddler_tests/tests/continuous_batch_generates_tokens_with_partial_layer_offload.rs b/paddler_tests/tests/continuous_batch_generates_tokens_with_partial_layer_offload.rs index 3b6da308..eadc64ff 100644 --- a/paddler_tests/tests/continuous_batch_generates_tokens_with_partial_layer_offload.rs +++ b/paddler_tests/tests/continuous_batch_generates_tokens_with_partial_layer_offload.rs @@ -8,6 +8,7 @@ use paddler_tests::inference_http_client::InferenceHttpClient; use paddler_tests::model_card::ModelCard; use paddler_tests::model_card::qwen3_0_6b::qwen3_0_6b; use paddler_tests::start_in_process_cluster::start_in_process_cluster; +use paddler_tests::token_result_with_producer::TokenResultWithProducer; use paddler_types::agent_desired_model::AgentDesiredModel; use paddler_types::balancer_desired_state::BalancerDesiredState; use paddler_types::generated_token_result::GeneratedTokenResult; @@ -59,13 +60,16 @@ async fn continuous_batch_generates_tokens_with_partial_layer_offload() -> Resul let token_count = collected .token_results .iter() - .filter(|result| result.is_token()) + .filter(|result| result.token_result.is_token()) .count(); assert!(token_count > 0); assert!(matches!( collected.token_results.last(), - Some(GeneratedTokenResult::Done(_)) + Some(TokenResultWithProducer { + token_result: GeneratedTokenResult::Done(_), + .. + }) )); cluster.shutdown().await?; diff --git a/paddler_tests/tests/continuous_batch_long_and_short_prompts_complete_concurrently.rs b/paddler_tests/tests/continuous_batch_long_and_short_prompts_complete_concurrently.rs index da037071..09b6ad53 100644 --- a/paddler_tests/tests/continuous_batch_long_and_short_prompts_complete_concurrently.rs +++ b/paddler_tests/tests/continuous_batch_long_and_short_prompts_complete_concurrently.rs @@ -4,6 +4,7 @@ use anyhow::Result; use paddler_tests::collect_generated_tokens::collect_generated_tokens; use paddler_tests::inference_http_client::InferenceHttpClient; use paddler_tests::start_in_process_cluster_with_qwen3::start_in_process_cluster_with_qwen3; +use paddler_tests::token_result_with_producer::TokenResultWithProducer; use paddler_types::generated_token_result::GeneratedTokenResult; use paddler_types::request_params::ContinueFromRawPromptParams; use reqwest::Client; @@ -45,23 +46,29 @@ async fn continuous_batch_long_and_short_prompts_complete_concurrently() -> Resu let long_tokens = long_collected .token_results .iter() - .filter(|result| result.is_token()) + .filter(|result| result.token_result.is_token()) .count(); let short_tokens = short_collected .token_results .iter() - .filter(|result| result.is_token()) + .filter(|result| result.token_result.is_token()) .count(); assert!(long_tokens > 0); assert!(short_tokens > 0); assert!(matches!( long_collected.token_results.last(), - Some(GeneratedTokenResult::Done(_)) + Some(TokenResultWithProducer { + token_result: GeneratedTokenResult::Done(_), + .. + }) )); assert!(matches!( short_collected.token_results.last(), - Some(GeneratedTokenResult::Done(_)) + Some(TokenResultWithProducer { + token_result: GeneratedTokenResult::Done(_), + .. + }) )); cluster.shutdown().await?; diff --git a/paddler_tests/tests/continuous_batch_plain_and_multimodal_run_concurrently.rs b/paddler_tests/tests/continuous_batch_plain_and_multimodal_run_concurrently.rs index 8f51aa9a..416b4aa4 100644 --- a/paddler_tests/tests/continuous_batch_plain_and_multimodal_run_concurrently.rs +++ b/paddler_tests/tests/continuous_batch_plain_and_multimodal_run_concurrently.rs @@ -5,6 +5,7 @@ use paddler_tests::collect_generated_tokens::collect_generated_tokens; use paddler_tests::inference_http_client::InferenceHttpClient; use paddler_tests::load_test_image_data_uri::load_test_image_data_uri; use paddler_tests::start_in_process_cluster_with_qwen3_5::start_in_process_cluster_with_qwen3_5; +use paddler_tests::token_result_with_producer::TokenResultWithProducer; use paddler_types::conversation_history::ConversationHistory; use paddler_types::conversation_message::ConversationMessage; use paddler_types::conversation_message_content::ConversationMessageContent; @@ -88,7 +89,7 @@ async fn continuous_batch_plain_and_multimodal_run_concurrently() -> Result<()> let token_count = collected .token_results .iter() - .filter(|result| result.is_token()) + .filter(|result| result.token_result.is_token()) .count(); assert!( @@ -99,12 +100,15 @@ async fn continuous_batch_plain_and_multimodal_run_concurrently() -> Result<()> !collected .token_results .iter() - .any(|result| matches!(result, GeneratedTokenResult::SamplerError(_))), + .any(|result| matches!(result.token_result, GeneratedTokenResult::SamplerError(_))), "concurrent {label} request must not surface a SamplerError" ); assert!(matches!( collected.token_results.last(), - Some(GeneratedTokenResult::Done(_)) + Some(TokenResultWithProducer { + token_result: GeneratedTokenResult::Done(_), + .. + }) )); } diff --git a/paddler_tests/tests/continuous_batch_reuses_slot_after_request_completes.rs b/paddler_tests/tests/continuous_batch_reuses_slot_after_request_completes.rs index b313169f..d3192fa1 100644 --- a/paddler_tests/tests/continuous_batch_reuses_slot_after_request_completes.rs +++ b/paddler_tests/tests/continuous_batch_reuses_slot_after_request_completes.rs @@ -4,6 +4,7 @@ use anyhow::Result; use paddler_tests::collect_generated_tokens::collect_generated_tokens; use paddler_tests::inference_http_client::InferenceHttpClient; use paddler_tests::start_in_process_cluster_with_qwen3::start_in_process_cluster_with_qwen3; +use paddler_tests::token_result_with_producer::TokenResultWithProducer; use paddler_types::generated_token_result::GeneratedTokenResult; use paddler_types::request_params::ContinueFromRawPromptParams; use reqwest::Client; @@ -28,7 +29,10 @@ async fn continuous_batch_reuses_slot_after_request_completes() -> Result<()> { assert!(matches!( first_collected.token_results.last(), - Some(GeneratedTokenResult::Done(_)) + Some(TokenResultWithProducer { + token_result: GeneratedTokenResult::Done(_), + .. + }) )); let second_stream = inference_client @@ -43,13 +47,16 @@ async fn continuous_batch_reuses_slot_after_request_completes() -> Result<()> { assert!(matches!( second_collected.token_results.last(), - Some(GeneratedTokenResult::Done(_)) + Some(TokenResultWithProducer { + token_result: GeneratedTokenResult::Done(_), + .. + }) )); let second_token_count = second_collected .token_results .iter() - .filter(|result| result.is_token()) + .filter(|result| result.token_result.is_token()) .count(); assert!( diff --git a/paddler_tests/tests/continuous_batch_serves_four_concurrent_requests.rs b/paddler_tests/tests/continuous_batch_serves_four_concurrent_requests.rs index f4b2bbf1..0488cca4 100644 --- a/paddler_tests/tests/continuous_batch_serves_four_concurrent_requests.rs +++ b/paddler_tests/tests/continuous_batch_serves_four_concurrent_requests.rs @@ -4,6 +4,7 @@ use anyhow::Result; use paddler_tests::collect_generated_tokens::collect_generated_tokens; use paddler_tests::inference_http_client::InferenceHttpClient; use paddler_tests::start_in_process_cluster_with_qwen3::start_in_process_cluster_with_qwen3; +use paddler_tests::token_result_with_producer::TokenResultWithProducer; use paddler_types::generated_token_result::GeneratedTokenResult; use paddler_types::request_params::ContinueFromRawPromptParams; use reqwest::Client; @@ -43,7 +44,7 @@ async fn continuous_batch_serves_four_concurrent_requests() -> Result<()> { let token_count = collected .token_results .iter() - .filter(|result| result.is_token()) + .filter(|result| result.token_result.is_token()) .count(); assert!( @@ -52,7 +53,10 @@ async fn continuous_batch_serves_four_concurrent_requests() -> Result<()> { ); assert!(matches!( collected.token_results.last(), - Some(GeneratedTokenResult::Done(_)) + Some(TokenResultWithProducer { + token_result: GeneratedTokenResult::Done(_), + .. + }) )); } diff --git a/paddler_tests/tests/continuous_batch_smoke.rs b/paddler_tests/tests/continuous_batch_smoke.rs index b99ab999..5b16c777 100644 --- a/paddler_tests/tests/continuous_batch_smoke.rs +++ b/paddler_tests/tests/continuous_batch_smoke.rs @@ -9,6 +9,7 @@ use paddler_tests::inference_http_client::InferenceHttpClient; use paddler_tests::model_card::ModelCard; use paddler_tests::model_card::qwen3_0_6b::qwen3_0_6b; use paddler_tests::start_in_process_cluster::start_in_process_cluster; +use paddler_tests::token_result_with_producer::TokenResultWithProducer; use paddler_types::agent_desired_model::AgentDesiredModel; use paddler_types::balancer_desired_state::BalancerDesiredState; use paddler_types::generated_token_result::GeneratedTokenResult; @@ -64,7 +65,7 @@ async fn continuous_batch_smoke_generates_tokens() -> Result<()> { let token_count = collected .token_results .iter() - .filter(|result| result.is_token()) + .filter(|result| result.token_result.is_token()) .count(); assert!( @@ -76,7 +77,10 @@ async fn continuous_batch_smoke_generates_tokens() -> Result<()> { assert!( matches!( collected.token_results.last(), - Some(GeneratedTokenResult::Done(_)) + Some(TokenResultWithProducer { + token_result: GeneratedTokenResult::Done(_), + .. + }) ), "smoke test stream did not terminate with Done" ); diff --git a/paddler_tests/tests/continuous_batch_stops_at_max_tokens_boundary.rs b/paddler_tests/tests/continuous_batch_stops_at_max_tokens_boundary.rs index 6053b5de..5900f7d7 100644 --- a/paddler_tests/tests/continuous_batch_stops_at_max_tokens_boundary.rs +++ b/paddler_tests/tests/continuous_batch_stops_at_max_tokens_boundary.rs @@ -4,6 +4,7 @@ use anyhow::Result; use paddler_tests::collect_generated_tokens::collect_generated_tokens; use paddler_tests::inference_http_client::InferenceHttpClient; use paddler_tests::start_in_process_cluster_with_qwen3::start_in_process_cluster_with_qwen3; +use paddler_tests::token_result_with_producer::TokenResultWithProducer; use paddler_types::generated_token_result::GeneratedTokenResult; use paddler_types::request_params::ContinueFromRawPromptParams; use reqwest::Client; @@ -29,7 +30,7 @@ async fn continuous_batch_stops_at_max_tokens_boundary() -> Result<()> { let token_count = collected .token_results .iter() - .filter(|result| result.is_token()) + .filter(|result| result.token_result.is_token()) .count(); assert_eq!( @@ -38,7 +39,10 @@ async fn continuous_batch_stops_at_max_tokens_boundary() -> Result<()> { ); assert!(matches!( collected.token_results.last(), - Some(GeneratedTokenResult::Done(_)) + Some(TokenResultWithProducer { + token_result: GeneratedTokenResult::Done(_), + .. + }) )); cluster.shutdown().await?; diff --git a/paddler_tests/tests/continuous_batch_stops_generation_when_stop_sender_dropped.rs b/paddler_tests/tests/continuous_batch_stops_generation_when_stop_sender_dropped.rs index cf03e502..3054c029 100644 --- a/paddler_tests/tests/continuous_batch_stops_generation_when_stop_sender_dropped.rs +++ b/paddler_tests/tests/continuous_batch_stops_generation_when_stop_sender_dropped.rs @@ -5,6 +5,7 @@ use futures_util::StreamExt as _; use paddler_tests::collect_generated_tokens::collect_generated_tokens; use paddler_tests::inference_http_client::InferenceHttpClient; use paddler_tests::start_in_process_cluster_with_qwen3::start_in_process_cluster_with_qwen3; +use paddler_tests::token_result_with_producer::TokenResultWithProducer; use paddler_types::generated_token_result::GeneratedTokenResult; use paddler_types::request_params::ContinueFromRawPromptParams; use reqwest::Client; @@ -44,13 +45,16 @@ async fn continuous_batch_stops_generation_when_stop_sender_dropped() -> Result< assert!(matches!( second_collected.token_results.last(), - Some(GeneratedTokenResult::Done(_)) + Some(TokenResultWithProducer { + token_result: GeneratedTokenResult::Done(_), + .. + }) )); let second_token_count = second_collected .token_results .iter() - .filter(|result| result.is_token()) + .filter(|result| result.token_result.is_token()) .count(); assert!( diff --git a/paddler_tests/tests/continuous_batch_two_concurrent_multimodal_requests_produce_tokens.rs b/paddler_tests/tests/continuous_batch_two_concurrent_multimodal_requests_produce_tokens.rs index e3f1433d..8c846cc1 100644 --- a/paddler_tests/tests/continuous_batch_two_concurrent_multimodal_requests_produce_tokens.rs +++ b/paddler_tests/tests/continuous_batch_two_concurrent_multimodal_requests_produce_tokens.rs @@ -5,6 +5,7 @@ use paddler_tests::collect_generated_tokens::collect_generated_tokens; use paddler_tests::inference_http_client::InferenceHttpClient; use paddler_tests::load_test_image_data_uri::load_test_image_data_uri; use paddler_tests::start_in_process_cluster_with_qwen3_5::start_in_process_cluster_with_qwen3_5; +use paddler_tests::token_result_with_producer::TokenResultWithProducer; use paddler_types::conversation_history::ConversationHistory; use paddler_types::conversation_message::ConversationMessage; use paddler_types::conversation_message_content::ConversationMessageContent; @@ -90,7 +91,7 @@ async fn continuous_batch_two_concurrent_multimodal_requests_produce_tokens() -> let token_count = collected .token_results .iter() - .filter(|result| result.is_token()) + .filter(|result| result.token_result.is_token()) .count(); assert!( @@ -101,12 +102,15 @@ async fn continuous_batch_two_concurrent_multimodal_requests_produce_tokens() -> !collected .token_results .iter() - .any(|result| matches!(result, GeneratedTokenResult::SamplerError(_))), + .any(|result| matches!(result.token_result, GeneratedTokenResult::SamplerError(_))), "concurrent multimodal request must not surface a SamplerError" ); assert!(matches!( collected.token_results.last(), - Some(GeneratedTokenResult::Done(_)) + Some(TokenResultWithProducer { + token_result: GeneratedTokenResult::Done(_), + .. + }) )); } diff --git a/paddler_tests/tests/deepseek_r1_distill_llama_8b_internal_endpoint_emits_reasoning_tokens.rs b/paddler_tests/tests/deepseek_r1_distill_llama_8b_internal_endpoint_emits_reasoning_tokens.rs index ebdc17f7..8ed1baf7 100644 --- a/paddler_tests/tests/deepseek_r1_distill_llama_8b_internal_endpoint_emits_reasoning_tokens.rs +++ b/paddler_tests/tests/deepseek_r1_distill_llama_8b_internal_endpoint_emits_reasoning_tokens.rs @@ -41,7 +41,7 @@ async fn deepseek_r1_distill_llama_8b_internal_endpoint_emits_reasoning_tokens() let reasoning_count = collected .token_results .iter() - .filter(|result| matches!(result, GeneratedTokenResult::ReasoningToken(_))) + .filter(|result| matches!(result.token_result, GeneratedTokenResult::ReasoningToken(_))) .count(); assert!( @@ -53,7 +53,7 @@ async fn deepseek_r1_distill_llama_8b_internal_endpoint_emits_reasoning_tokens() .token_results .last() .ok_or_else(|| anyhow::anyhow!("no token results received"))?; - let GeneratedTokenResult::Done(summary) = last else { + let GeneratedTokenResult::Done(summary) = &last.token_result else { anyhow::bail!("last result was not Done: {last:?}"); }; @@ -69,7 +69,7 @@ async fn deepseek_r1_distill_llama_8b_internal_endpoint_emits_reasoning_tokens() let reasoning_stream: String = collected .token_results .iter() - .filter_map(|result| match result { + .filter_map(|result| match &result.token_result { GeneratedTokenResult::ReasoningToken(piece) => Some(piece.as_str()), _ => None, }) @@ -77,7 +77,7 @@ async fn deepseek_r1_distill_llama_8b_internal_endpoint_emits_reasoning_tokens() let content_stream: String = collected .token_results .iter() - .filter_map(|result| match result { + .filter_map(|result| match &result.token_result { GeneratedTokenResult::ContentToken(piece) => Some(piece.as_str()), _ => None, }) diff --git a/paddler_tests/tests/gemma4_internal_endpoint_emits_reasoning_tokens.rs b/paddler_tests/tests/gemma4_internal_endpoint_emits_reasoning_tokens.rs index 08253cd7..40b74edc 100644 --- a/paddler_tests/tests/gemma4_internal_endpoint_emits_reasoning_tokens.rs +++ b/paddler_tests/tests/gemma4_internal_endpoint_emits_reasoning_tokens.rs @@ -41,7 +41,7 @@ async fn gemma4_internal_endpoint_emits_reasoning_tokens() -> Result<()> { let reasoning_count = collected .token_results .iter() - .filter(|result| matches!(result, GeneratedTokenResult::ReasoningToken(_))) + .filter(|result| matches!(result.token_result, GeneratedTokenResult::ReasoningToken(_))) .count(); assert!( @@ -53,7 +53,7 @@ async fn gemma4_internal_endpoint_emits_reasoning_tokens() -> Result<()> { .token_results .last() .ok_or_else(|| anyhow::anyhow!("no token results received"))?; - let GeneratedTokenResult::Done(summary) = last else { + let GeneratedTokenResult::Done(summary) = &last.token_result else { anyhow::bail!("last result was not Done: {last:?}"); }; @@ -69,7 +69,7 @@ async fn gemma4_internal_endpoint_emits_reasoning_tokens() -> Result<()> { let reasoning_stream: String = collected .token_results .iter() - .filter_map(|result| match result { + .filter_map(|result| match &result.token_result { GeneratedTokenResult::ReasoningToken(piece) => Some(piece.as_str()), _ => None, }) @@ -77,7 +77,7 @@ async fn gemma4_internal_endpoint_emits_reasoning_tokens() -> Result<()> { let content_stream: String = collected .token_results .iter() - .filter_map(|result| match result { + .filter_map(|result| match &result.token_result { GeneratedTokenResult::ContentToken(piece) => Some(piece.as_str()), _ => None, }) diff --git a/paddler_tests/tests/gemma4_internal_endpoint_emits_reasoning_tokens_for_image_request.rs b/paddler_tests/tests/gemma4_internal_endpoint_emits_reasoning_tokens_for_image_request.rs index 12caeb60..6b8f41ac 100644 --- a/paddler_tests/tests/gemma4_internal_endpoint_emits_reasoning_tokens_for_image_request.rs +++ b/paddler_tests/tests/gemma4_internal_endpoint_emits_reasoning_tokens_for_image_request.rs @@ -55,7 +55,7 @@ async fn gemma4_internal_endpoint_emits_reasoning_tokens_for_image_request() -> let reasoning_count = collected .token_results .iter() - .filter(|result| matches!(result, GeneratedTokenResult::ReasoningToken(_))) + .filter(|result| matches!(result.token_result, GeneratedTokenResult::ReasoningToken(_))) .count(); assert!( @@ -67,7 +67,7 @@ async fn gemma4_internal_endpoint_emits_reasoning_tokens_for_image_request() -> .token_results .last() .ok_or_else(|| anyhow::anyhow!("no token results received"))?; - let GeneratedTokenResult::Done(summary) = last else { + let GeneratedTokenResult::Done(summary) = &last.token_result else { anyhow::bail!("last result was not Done: {last:?}"); }; diff --git a/paddler_tests/tests/gemma4_internal_endpoint_emits_tool_call_parsed_event.rs b/paddler_tests/tests/gemma4_internal_endpoint_emits_tool_call_parsed_event.rs index 6e2b28b2..071010d1 100644 --- a/paddler_tests/tests/gemma4_internal_endpoint_emits_tool_call_parsed_event.rs +++ b/paddler_tests/tests/gemma4_internal_endpoint_emits_tool_call_parsed_event.rs @@ -66,7 +66,7 @@ async fn gemma4_internal_endpoint_emits_tool_call_parsed_event() -> Result<()> { let parsed_events: Vec<&Vec> = collected .token_results .iter() - .filter_map(|event| match event { + .filter_map(|event| match &event.token_result { GeneratedTokenResult::ToolCallParsed(parsed) => Some(parsed), _ => None, }) diff --git a/paddler_tests/tests/glm_4_7_flash_internal_endpoint_emits_reasoning_tokens.rs b/paddler_tests/tests/glm_4_7_flash_internal_endpoint_emits_reasoning_tokens.rs index dfdd3a33..dfb00268 100644 --- a/paddler_tests/tests/glm_4_7_flash_internal_endpoint_emits_reasoning_tokens.rs +++ b/paddler_tests/tests/glm_4_7_flash_internal_endpoint_emits_reasoning_tokens.rs @@ -41,7 +41,7 @@ async fn glm_4_7_flash_internal_endpoint_emits_reasoning_tokens() -> Result<()> let reasoning_count = collected .token_results .iter() - .filter(|result| matches!(result, GeneratedTokenResult::ReasoningToken(_))) + .filter(|result| matches!(result.token_result, GeneratedTokenResult::ReasoningToken(_))) .count(); assert!( @@ -53,7 +53,7 @@ async fn glm_4_7_flash_internal_endpoint_emits_reasoning_tokens() -> Result<()> .token_results .last() .ok_or_else(|| anyhow::anyhow!("no token results received"))?; - let GeneratedTokenResult::Done(summary) = last else { + let GeneratedTokenResult::Done(summary) = &last.token_result else { anyhow::bail!("last result was not Done: {last:?}"); }; @@ -69,7 +69,7 @@ async fn glm_4_7_flash_internal_endpoint_emits_reasoning_tokens() -> Result<()> let reasoning_stream: String = collected .token_results .iter() - .filter_map(|result| match result { + .filter_map(|result| match &result.token_result { GeneratedTokenResult::ReasoningToken(piece) => Some(piece.as_str()), _ => None, }) @@ -77,7 +77,7 @@ async fn glm_4_7_flash_internal_endpoint_emits_reasoning_tokens() -> Result<()> let content_stream: String = collected .token_results .iter() - .filter_map(|result| match result { + .filter_map(|result| match &result.token_result { GeneratedTokenResult::ContentToken(piece) => Some(piece.as_str()), _ => None, }) diff --git a/paddler_tests/tests/glm_4_7_flash_internal_endpoint_emits_tool_call_parsed_event.rs b/paddler_tests/tests/glm_4_7_flash_internal_endpoint_emits_tool_call_parsed_event.rs index c071836b..a025041d 100644 --- a/paddler_tests/tests/glm_4_7_flash_internal_endpoint_emits_tool_call_parsed_event.rs +++ b/paddler_tests/tests/glm_4_7_flash_internal_endpoint_emits_tool_call_parsed_event.rs @@ -66,7 +66,7 @@ async fn glm_4_7_flash_internal_endpoint_emits_tool_call_parsed_event() -> Resul let parsed_events: Vec<&Vec> = collected .token_results .iter() - .filter_map(|event| match event { + .filter_map(|event| match &event.token_result { GeneratedTokenResult::ToolCallParsed(parsed) => Some(parsed), _ => None, }) diff --git a/paddler_tests/tests/mistral3_internal_endpoint_emits_reasoning_tokens.rs b/paddler_tests/tests/mistral3_internal_endpoint_emits_reasoning_tokens.rs index 2451eee3..cb0de139 100644 --- a/paddler_tests/tests/mistral3_internal_endpoint_emits_reasoning_tokens.rs +++ b/paddler_tests/tests/mistral3_internal_endpoint_emits_reasoning_tokens.rs @@ -41,7 +41,7 @@ async fn mistral3_internal_endpoint_emits_reasoning_tokens() -> Result<()> { let reasoning_count = collected .token_results .iter() - .filter(|result| matches!(result, GeneratedTokenResult::ReasoningToken(_))) + .filter(|result| matches!(result.token_result, GeneratedTokenResult::ReasoningToken(_))) .count(); assert!( @@ -53,7 +53,7 @@ async fn mistral3_internal_endpoint_emits_reasoning_tokens() -> Result<()> { .token_results .last() .ok_or_else(|| anyhow::anyhow!("no token results received"))?; - let GeneratedTokenResult::Done(summary) = last else { + let GeneratedTokenResult::Done(summary) = &last.token_result else { anyhow::bail!("last result was not Done: {last:?}"); }; @@ -69,7 +69,7 @@ async fn mistral3_internal_endpoint_emits_reasoning_tokens() -> Result<()> { let reasoning_stream: String = collected .token_results .iter() - .filter_map(|result| match result { + .filter_map(|result| match &result.token_result { GeneratedTokenResult::ReasoningToken(piece) => Some(piece.as_str()), _ => None, }) @@ -77,7 +77,7 @@ async fn mistral3_internal_endpoint_emits_reasoning_tokens() -> Result<()> { let content_stream: String = collected .token_results .iter() - .filter_map(|result| match result { + .filter_map(|result| match &result.token_result { GeneratedTokenResult::ContentToken(piece) => Some(piece.as_str()), _ => None, }) diff --git a/paddler_tests/tests/mistral3_internal_endpoint_emits_reasoning_tokens_for_image_request.rs b/paddler_tests/tests/mistral3_internal_endpoint_emits_reasoning_tokens_for_image_request.rs index 385bade9..defeb7a2 100644 --- a/paddler_tests/tests/mistral3_internal_endpoint_emits_reasoning_tokens_for_image_request.rs +++ b/paddler_tests/tests/mistral3_internal_endpoint_emits_reasoning_tokens_for_image_request.rs @@ -55,7 +55,7 @@ async fn mistral3_internal_endpoint_emits_reasoning_tokens_for_image_request() - let reasoning_count = collected .token_results .iter() - .filter(|result| matches!(result, GeneratedTokenResult::ReasoningToken(_))) + .filter(|result| matches!(result.token_result, GeneratedTokenResult::ReasoningToken(_))) .count(); assert!( @@ -67,7 +67,7 @@ async fn mistral3_internal_endpoint_emits_reasoning_tokens_for_image_request() - .token_results .last() .ok_or_else(|| anyhow::anyhow!("no token results received"))?; - let GeneratedTokenResult::Done(summary) = last else { + let GeneratedTokenResult::Done(summary) = &last.token_result else { anyhow::bail!("last result was not Done: {last:?}"); }; diff --git a/paddler_tests/tests/mistral3_internal_endpoint_emits_tool_call_parsed_event.rs b/paddler_tests/tests/mistral3_internal_endpoint_emits_tool_call_parsed_event.rs index 473395c6..8fe06b21 100644 --- a/paddler_tests/tests/mistral3_internal_endpoint_emits_tool_call_parsed_event.rs +++ b/paddler_tests/tests/mistral3_internal_endpoint_emits_tool_call_parsed_event.rs @@ -66,7 +66,7 @@ async fn mistral3_internal_endpoint_emits_tool_call_parsed_event() -> Result<()> let parsed_events: Vec<&Vec> = collected .token_results .iter() - .filter_map(|event| match event { + .filter_map(|event| match &event.token_result { GeneratedTokenResult::ToolCallParsed(parsed) => Some(parsed), _ => None, }) diff --git a/paddler_tests/tests/qwen25vl_generates_tokens_from_image_input.rs b/paddler_tests/tests/qwen25vl_generates_tokens_from_image_input.rs index c424573d..111c2e02 100644 --- a/paddler_tests/tests/qwen25vl_generates_tokens_from_image_input.rs +++ b/paddler_tests/tests/qwen25vl_generates_tokens_from_image_input.rs @@ -5,6 +5,7 @@ use paddler_tests::collect_generated_tokens::collect_generated_tokens; use paddler_tests::inference_http_client::InferenceHttpClient; use paddler_tests::load_test_image_data_uri::load_test_image_data_uri; use paddler_tests::start_in_process_cluster_with_qwen2_5_vl::start_in_process_cluster_with_qwen2_5_vl; +use paddler_tests::token_result_with_producer::TokenResultWithProducer; use paddler_types::conversation_history::ConversationHistory; use paddler_types::conversation_message::ConversationMessage; use paddler_types::conversation_message_content::ConversationMessageContent; @@ -55,13 +56,16 @@ async fn qwen25vl_generates_tokens_from_image_input() -> Result<()> { let token_count = collected .token_results .iter() - .filter(|result| result.is_token()) + .filter(|result| result.token_result.is_token()) .count(); assert!(token_count > 0); assert!(matches!( collected.token_results.last(), - Some(GeneratedTokenResult::Done(_)) + Some(TokenResultWithProducer { + token_result: GeneratedTokenResult::Done(_), + .. + }) )); cluster.shutdown().await?; diff --git a/paddler_tests/tests/qwen35_generates_tokens_for_long_system_and_user_prompt.rs b/paddler_tests/tests/qwen35_generates_tokens_for_long_system_and_user_prompt.rs index 344b1a42..dd2cfe36 100644 --- a/paddler_tests/tests/qwen35_generates_tokens_for_long_system_and_user_prompt.rs +++ b/paddler_tests/tests/qwen35_generates_tokens_for_long_system_and_user_prompt.rs @@ -4,6 +4,7 @@ use anyhow::Result; use paddler_tests::collect_generated_tokens::collect_generated_tokens; use paddler_tests::inference_http_client::InferenceHttpClient; use paddler_tests::start_in_process_cluster_with_qwen3_5::start_in_process_cluster_with_qwen3_5; +use paddler_tests::token_result_with_producer::TokenResultWithProducer; use paddler_types::conversation_history::ConversationHistory; use paddler_types::conversation_message::ConversationMessage; use paddler_types::conversation_message_content::ConversationMessageContent; @@ -91,13 +92,16 @@ async fn qwen35_generates_tokens_for_long_system_and_user_prompt() -> Result<()> let token_count = collected .token_results .iter() - .filter(|result| result.is_token()) + .filter(|result| result.token_result.is_token()) .count(); assert!(token_count > 0); assert!(matches!( collected.token_results.last(), - Some(GeneratedTokenResult::Done(_)) + Some(TokenResultWithProducer { + token_result: GeneratedTokenResult::Done(_), + .. + }) )); cluster.shutdown().await?; diff --git a/paddler_tests/tests/qwen35_generation_stops_at_eog_before_max_tokens.rs b/paddler_tests/tests/qwen35_generation_stops_at_eog_before_max_tokens.rs index 2e34ebae..cb7b44d7 100644 --- a/paddler_tests/tests/qwen35_generation_stops_at_eog_before_max_tokens.rs +++ b/paddler_tests/tests/qwen35_generation_stops_at_eog_before_max_tokens.rs @@ -4,6 +4,7 @@ use anyhow::Result; use paddler_tests::collect_generated_tokens::collect_generated_tokens; use paddler_tests::inference_http_client::InferenceHttpClient; use paddler_tests::start_in_process_cluster_with_qwen3_5::start_in_process_cluster_with_qwen3_5; +use paddler_tests::token_result_with_producer::TokenResultWithProducer; use paddler_types::conversation_history::ConversationHistory; use paddler_types::conversation_message::ConversationMessage; use paddler_types::conversation_message_content::ConversationMessageContent; @@ -39,7 +40,7 @@ async fn qwen35_generation_stops_at_eog_before_max_tokens() -> Result<()> { let token_count = collected .token_results .iter() - .filter(|result| result.is_token()) + .filter(|result| result.token_result.is_token()) .count(); assert!(token_count > 0); @@ -49,7 +50,10 @@ async fn qwen35_generation_stops_at_eog_before_max_tokens() -> Result<()> { ); assert!(matches!( collected.token_results.last(), - Some(GeneratedTokenResult::Done(_)) + Some(TokenResultWithProducer { + token_result: GeneratedTokenResult::Done(_), + .. + }) )); cluster.shutdown().await?; diff --git a/paddler_tests/tests/qwen35_internal_endpoint_emits_reasoning_tokens_for_image_request.rs b/paddler_tests/tests/qwen35_internal_endpoint_emits_reasoning_tokens_for_image_request.rs index 10755c6e..0014feea 100644 --- a/paddler_tests/tests/qwen35_internal_endpoint_emits_reasoning_tokens_for_image_request.rs +++ b/paddler_tests/tests/qwen35_internal_endpoint_emits_reasoning_tokens_for_image_request.rs @@ -55,7 +55,7 @@ async fn qwen35_internal_endpoint_emits_reasoning_tokens_for_image_request() -> let reasoning_count = collected .token_results .iter() - .filter(|result| matches!(result, GeneratedTokenResult::ReasoningToken(_))) + .filter(|result| matches!(result.token_result, GeneratedTokenResult::ReasoningToken(_))) .count(); assert!( @@ -67,7 +67,7 @@ async fn qwen35_internal_endpoint_emits_reasoning_tokens_for_image_request() -> .token_results .last() .ok_or_else(|| anyhow::anyhow!("no token results received"))?; - let GeneratedTokenResult::Done(summary) = last else { + let GeneratedTokenResult::Done(summary) = &last.token_result else { anyhow::bail!("last result was not Done: {last:?}"); }; diff --git a/paddler_tests/tests/qwen35_internal_endpoint_emits_tool_call_parsed_event.rs b/paddler_tests/tests/qwen35_internal_endpoint_emits_tool_call_parsed_event.rs index b18d5b46..7da3a578 100644 --- a/paddler_tests/tests/qwen35_internal_endpoint_emits_tool_call_parsed_event.rs +++ b/paddler_tests/tests/qwen35_internal_endpoint_emits_tool_call_parsed_event.rs @@ -66,7 +66,7 @@ async fn qwen35_internal_endpoint_emits_tool_call_parsed_event() -> Result<()> { let parsed_events: Vec<&Vec> = collected .token_results .iter() - .filter_map(|event| match event { + .filter_map(|event| match &event.token_result { GeneratedTokenResult::ToolCallParsed(parsed) => Some(parsed), _ => None, }) diff --git a/paddler_tests/tests/qwen35_internal_endpoint_with_thinking_disabled_emits_only_content_tokens.rs b/paddler_tests/tests/qwen35_internal_endpoint_with_thinking_disabled_emits_only_content_tokens.rs index 984d201a..2024c1f7 100644 --- a/paddler_tests/tests/qwen35_internal_endpoint_with_thinking_disabled_emits_only_content_tokens.rs +++ b/paddler_tests/tests/qwen35_internal_endpoint_with_thinking_disabled_emits_only_content_tokens.rs @@ -39,17 +39,22 @@ async fn qwen35_internal_endpoint_with_thinking_disabled_emits_only_content_toke let reasoning_count = collected .token_results .iter() - .filter(|result| matches!(result, GeneratedTokenResult::ReasoningToken(_))) + .filter(|result| matches!(result.token_result, GeneratedTokenResult::ReasoningToken(_))) .count(); let content_count = collected .token_results .iter() - .filter(|result| matches!(result, GeneratedTokenResult::ContentToken(_))) + .filter(|result| matches!(result.token_result, GeneratedTokenResult::ContentToken(_))) .count(); let undeterminable_count = collected .token_results .iter() - .filter(|result| matches!(result, GeneratedTokenResult::UndeterminableToken(_))) + .filter(|result| { + matches!( + result.token_result, + GeneratedTokenResult::UndeterminableToken(_) + ) + }) .count(); assert_eq!( @@ -74,7 +79,7 @@ async fn qwen35_internal_endpoint_with_thinking_disabled_emits_only_content_toke .token_results .last() .ok_or_else(|| anyhow::anyhow!("no token results received"))?; - let GeneratedTokenResult::Done(summary) = last else { + let GeneratedTokenResult::Done(summary) = &last.token_result else { anyhow::bail!("last result was not Done: {last:?}"); }; @@ -99,7 +104,7 @@ async fn qwen35_internal_endpoint_with_thinking_disabled_emits_only_content_toke let reasoning_stream: String = collected .token_results .iter() - .filter_map(|result| match result { + .filter_map(|result| match &result.token_result { GeneratedTokenResult::ReasoningToken(piece) => Some(piece.as_str()), _ => None, }) @@ -107,7 +112,7 @@ async fn qwen35_internal_endpoint_with_thinking_disabled_emits_only_content_toke let content_stream: String = collected .token_results .iter() - .filter_map(|result| match result { + .filter_map(|result| match &result.token_result { GeneratedTokenResult::ContentToken(piece) => Some(piece.as_str()), _ => None, }) diff --git a/paddler_tests/tests/qwen35_internal_endpoint_with_thinking_enabled_emits_reasoning_tokens.rs b/paddler_tests/tests/qwen35_internal_endpoint_with_thinking_enabled_emits_reasoning_tokens.rs index 8302da3e..0627e373 100644 --- a/paddler_tests/tests/qwen35_internal_endpoint_with_thinking_enabled_emits_reasoning_tokens.rs +++ b/paddler_tests/tests/qwen35_internal_endpoint_with_thinking_enabled_emits_reasoning_tokens.rs @@ -41,7 +41,7 @@ async fn qwen35_internal_endpoint_with_thinking_enabled_emits_reasoning_tokens() let reasoning_count = collected .token_results .iter() - .filter(|result| matches!(result, GeneratedTokenResult::ReasoningToken(_))) + .filter(|result| matches!(result.token_result, GeneratedTokenResult::ReasoningToken(_))) .count(); assert!( @@ -53,7 +53,7 @@ async fn qwen35_internal_endpoint_with_thinking_enabled_emits_reasoning_tokens() .token_results .last() .ok_or_else(|| anyhow::anyhow!("no token results received"))?; - let GeneratedTokenResult::Done(summary) = last else { + let GeneratedTokenResult::Done(summary) = &last.token_result else { anyhow::bail!("last result was not Done: {last:?}"); }; @@ -69,7 +69,7 @@ async fn qwen35_internal_endpoint_with_thinking_enabled_emits_reasoning_tokens() let reasoning_stream: String = collected .token_results .iter() - .filter_map(|result| match result { + .filter_map(|result| match &result.token_result { GeneratedTokenResult::ReasoningToken(piece) => Some(piece.as_str()), _ => None, }) @@ -77,7 +77,7 @@ async fn qwen35_internal_endpoint_with_thinking_enabled_emits_reasoning_tokens() let content_stream: String = collected .token_results .iter() - .filter_map(|result| match result { + .filter_map(|result| match &result.token_result { GeneratedTokenResult::ContentToken(piece) => Some(piece.as_str()), _ => None, }) diff --git a/paddler_tests/tests/qwen35_thinking_mode_stops_cleanly_before_max_tokens.rs b/paddler_tests/tests/qwen35_thinking_mode_stops_cleanly_before_max_tokens.rs index 5abf0142..67e5ab43 100644 --- a/paddler_tests/tests/qwen35_thinking_mode_stops_cleanly_before_max_tokens.rs +++ b/paddler_tests/tests/qwen35_thinking_mode_stops_cleanly_before_max_tokens.rs @@ -4,6 +4,7 @@ use anyhow::Result; use paddler_tests::collect_generated_tokens::collect_generated_tokens; use paddler_tests::inference_http_client::InferenceHttpClient; use paddler_tests::start_in_process_cluster_with_qwen3_5::start_in_process_cluster_with_qwen3_5; +use paddler_tests::token_result_with_producer::TokenResultWithProducer; use paddler_types::conversation_history::ConversationHistory; use paddler_types::conversation_message::ConversationMessage; use paddler_types::conversation_message_content::ConversationMessageContent; @@ -39,14 +40,17 @@ async fn qwen35_thinking_mode_stops_cleanly_before_max_tokens() -> Result<()> { let token_count = collected .token_results .iter() - .filter(|result| result.is_token()) + .filter(|result| result.token_result.is_token()) .count(); assert!(token_count > 0); assert!(token_count <= 2000); assert!(matches!( collected.token_results.last(), - Some(GeneratedTokenResult::Done(_)) + Some(TokenResultWithProducer { + token_result: GeneratedTokenResult::Done(_), + .. + }) )); cluster.shutdown().await?; diff --git a/paddler_tests/tests/qwen35_thinking_multi_turn_conversation_stops_cleanly.rs b/paddler_tests/tests/qwen35_thinking_multi_turn_conversation_stops_cleanly.rs index 73b74de5..24c261ba 100644 --- a/paddler_tests/tests/qwen35_thinking_multi_turn_conversation_stops_cleanly.rs +++ b/paddler_tests/tests/qwen35_thinking_multi_turn_conversation_stops_cleanly.rs @@ -4,6 +4,7 @@ use anyhow::Result; use paddler_tests::collect_generated_tokens::collect_generated_tokens; use paddler_tests::inference_http_client::InferenceHttpClient; use paddler_tests::start_in_process_cluster_with_qwen3_5::start_in_process_cluster_with_qwen3_5; +use paddler_tests::token_result_with_producer::TokenResultWithProducer; use paddler_types::conversation_history::ConversationHistory; use paddler_types::conversation_message::ConversationMessage; use paddler_types::conversation_message_content::ConversationMessageContent; @@ -55,14 +56,17 @@ async fn qwen35_thinking_multi_turn_conversation_stops_cleanly() -> Result<()> { let token_count = collected .token_results .iter() - .filter(|result| result.is_token()) + .filter(|result| result.token_result.is_token()) .count(); assert!(token_count > 0); assert!(token_count <= 1000); assert!(matches!( collected.token_results.last(), - Some(GeneratedTokenResult::Done(_)) + Some(TokenResultWithProducer { + token_result: GeneratedTokenResult::Done(_), + .. + }) )); cluster.shutdown().await?; diff --git a/paddler_tests/tests/qwen35_with_mmproj_generates_tokens_from_image.rs b/paddler_tests/tests/qwen35_with_mmproj_generates_tokens_from_image.rs index af61b03e..a224b4ea 100644 --- a/paddler_tests/tests/qwen35_with_mmproj_generates_tokens_from_image.rs +++ b/paddler_tests/tests/qwen35_with_mmproj_generates_tokens_from_image.rs @@ -5,6 +5,7 @@ use paddler_tests::collect_generated_tokens::collect_generated_tokens; use paddler_tests::inference_http_client::InferenceHttpClient; use paddler_tests::load_test_image_data_uri::load_test_image_data_uri; use paddler_tests::start_in_process_cluster_with_qwen3_5::start_in_process_cluster_with_qwen3_5; +use paddler_tests::token_result_with_producer::TokenResultWithProducer; use paddler_types::conversation_history::ConversationHistory; use paddler_types::conversation_message::ConversationMessage; use paddler_types::conversation_message_content::ConversationMessageContent; @@ -55,13 +56,16 @@ async fn qwen35_with_mmproj_generates_tokens_from_image() -> Result<()> { let token_count = collected .token_results .iter() - .filter(|result| result.is_token()) + .filter(|result| result.token_result.is_token()) .count(); assert!(token_count > 0); assert!(matches!( collected.token_results.last(), - Some(GeneratedTokenResult::Done(_)) + Some(TokenResultWithProducer { + token_result: GeneratedTokenResult::Done(_), + .. + }) )); cluster.shutdown().await?; diff --git a/paddler_tests/tests/qwen35_with_system_message_completes_with_thinking.rs b/paddler_tests/tests/qwen35_with_system_message_completes_with_thinking.rs index 141ce1ee..a592b85e 100644 --- a/paddler_tests/tests/qwen35_with_system_message_completes_with_thinking.rs +++ b/paddler_tests/tests/qwen35_with_system_message_completes_with_thinking.rs @@ -4,6 +4,7 @@ use anyhow::Result; use paddler_tests::collect_generated_tokens::collect_generated_tokens; use paddler_tests::inference_http_client::InferenceHttpClient; use paddler_tests::start_in_process_cluster_with_qwen3_5::start_in_process_cluster_with_qwen3_5; +use paddler_tests::token_result_with_producer::TokenResultWithProducer; use paddler_types::conversation_history::ConversationHistory; use paddler_types::conversation_message::ConversationMessage; use paddler_types::conversation_message_content::ConversationMessageContent; @@ -51,13 +52,16 @@ async fn qwen35_with_system_message_completes_with_thinking() -> Result<()> { let token_count = collected .token_results .iter() - .filter(|result| result.is_token()) + .filter(|result| result.token_result.is_token()) .count(); assert!(token_count > 0); assert!(matches!( collected.token_results.last(), - Some(GeneratedTokenResult::Done(_)) + Some(TokenResultWithProducer { + token_result: GeneratedTokenResult::Done(_), + .. + }) )); cluster.shutdown().await?; diff --git a/paddler_tests/tests/qwen35_with_system_message_completes_without_thinking.rs b/paddler_tests/tests/qwen35_with_system_message_completes_without_thinking.rs index 99726031..d088edd5 100644 --- a/paddler_tests/tests/qwen35_with_system_message_completes_without_thinking.rs +++ b/paddler_tests/tests/qwen35_with_system_message_completes_without_thinking.rs @@ -4,6 +4,7 @@ use anyhow::Result; use paddler_tests::collect_generated_tokens::collect_generated_tokens; use paddler_tests::inference_http_client::InferenceHttpClient; use paddler_tests::start_in_process_cluster_with_qwen3_5::start_in_process_cluster_with_qwen3_5; +use paddler_tests::token_result_with_producer::TokenResultWithProducer; use paddler_types::conversation_history::ConversationHistory; use paddler_types::conversation_message::ConversationMessage; use paddler_types::conversation_message_content::ConversationMessageContent; @@ -51,13 +52,16 @@ async fn qwen35_with_system_message_completes_without_thinking() -> Result<()> { let token_count = collected .token_results .iter() - .filter(|result| result.is_token()) + .filter(|result| result.token_result.is_token()) .count(); assert!(token_count > 0); assert!(matches!( collected.token_results.last(), - Some(GeneratedTokenResult::Done(_)) + Some(TokenResultWithProducer { + token_result: GeneratedTokenResult::Done(_), + .. + }) )); cluster.shutdown().await?; diff --git a/paddler_tests/tests/qwen35_without_mmproj_rejects_image_with_multimodal_not_supported.rs b/paddler_tests/tests/qwen35_without_mmproj_rejects_image_with_multimodal_not_supported.rs index 616b439f..5c862278 100644 --- a/paddler_tests/tests/qwen35_without_mmproj_rejects_image_with_multimodal_not_supported.rs +++ b/paddler_tests/tests/qwen35_without_mmproj_rejects_image_with_multimodal_not_supported.rs @@ -56,7 +56,7 @@ async fn qwen35_without_mmproj_rejects_image_with_multimodal_not_supported() -> if let Ok(collected) = collected { assert!( collected.token_results.iter().any(|result| matches!( - result, + result.token_result, GeneratedTokenResult::MultimodalNotSupported(_) )), "expected MultimodalNotSupported, got: {:?}", diff --git a/paddler_tests/tests/qwen36_internal_endpoint_emits_reasoning_tokens_for_image_request.rs b/paddler_tests/tests/qwen36_internal_endpoint_emits_reasoning_tokens_for_image_request.rs index 449cd8b9..469380a1 100644 --- a/paddler_tests/tests/qwen36_internal_endpoint_emits_reasoning_tokens_for_image_request.rs +++ b/paddler_tests/tests/qwen36_internal_endpoint_emits_reasoning_tokens_for_image_request.rs @@ -55,7 +55,7 @@ async fn qwen36_internal_endpoint_emits_reasoning_tokens_for_image_request() -> let reasoning_count = collected .token_results .iter() - .filter(|result| matches!(result, GeneratedTokenResult::ReasoningToken(_))) + .filter(|result| matches!(result.token_result, GeneratedTokenResult::ReasoningToken(_))) .count(); assert!( @@ -67,7 +67,7 @@ async fn qwen36_internal_endpoint_emits_reasoning_tokens_for_image_request() -> .token_results .last() .ok_or_else(|| anyhow::anyhow!("no token results received"))?; - let GeneratedTokenResult::Done(summary) = last else { + let GeneratedTokenResult::Done(summary) = &last.token_result else { anyhow::bail!("last result was not Done: {last:?}"); }; diff --git a/paddler_tests/tests/qwen36_internal_endpoint_emits_tool_call_parsed_event.rs b/paddler_tests/tests/qwen36_internal_endpoint_emits_tool_call_parsed_event.rs index daaf5a54..d138fa0b 100644 --- a/paddler_tests/tests/qwen36_internal_endpoint_emits_tool_call_parsed_event.rs +++ b/paddler_tests/tests/qwen36_internal_endpoint_emits_tool_call_parsed_event.rs @@ -66,7 +66,7 @@ async fn qwen36_internal_endpoint_emits_tool_call_parsed_event() -> Result<()> { let parsed_events: Vec<&Vec> = collected .token_results .iter() - .filter_map(|event| match event { + .filter_map(|event| match &event.token_result { GeneratedTokenResult::ToolCallParsed(parsed) => Some(parsed), _ => None, }) diff --git a/paddler_tests/tests/qwen36_internal_endpoint_with_thinking_disabled_emits_only_content_tokens.rs b/paddler_tests/tests/qwen36_internal_endpoint_with_thinking_disabled_emits_only_content_tokens.rs index 58d8208d..ae255987 100644 --- a/paddler_tests/tests/qwen36_internal_endpoint_with_thinking_disabled_emits_only_content_tokens.rs +++ b/paddler_tests/tests/qwen36_internal_endpoint_with_thinking_disabled_emits_only_content_tokens.rs @@ -39,17 +39,22 @@ async fn qwen36_internal_endpoint_with_thinking_disabled_emits_only_content_toke let reasoning_count = collected .token_results .iter() - .filter(|result| matches!(result, GeneratedTokenResult::ReasoningToken(_))) + .filter(|result| matches!(result.token_result, GeneratedTokenResult::ReasoningToken(_))) .count(); let content_count = collected .token_results .iter() - .filter(|result| matches!(result, GeneratedTokenResult::ContentToken(_))) + .filter(|result| matches!(result.token_result, GeneratedTokenResult::ContentToken(_))) .count(); let undeterminable_count = collected .token_results .iter() - .filter(|result| matches!(result, GeneratedTokenResult::UndeterminableToken(_))) + .filter(|result| { + matches!( + result.token_result, + GeneratedTokenResult::UndeterminableToken(_) + ) + }) .count(); assert_eq!( @@ -74,7 +79,7 @@ async fn qwen36_internal_endpoint_with_thinking_disabled_emits_only_content_toke .token_results .last() .ok_or_else(|| anyhow::anyhow!("no token results received"))?; - let GeneratedTokenResult::Done(summary) = last else { + let GeneratedTokenResult::Done(summary) = &last.token_result else { anyhow::bail!("last result was not Done: {last:?}"); }; @@ -99,7 +104,7 @@ async fn qwen36_internal_endpoint_with_thinking_disabled_emits_only_content_toke let reasoning_stream: String = collected .token_results .iter() - .filter_map(|result| match result { + .filter_map(|result| match &result.token_result { GeneratedTokenResult::ReasoningToken(piece) => Some(piece.as_str()), _ => None, }) @@ -107,7 +112,7 @@ async fn qwen36_internal_endpoint_with_thinking_disabled_emits_only_content_toke let content_stream: String = collected .token_results .iter() - .filter_map(|result| match result { + .filter_map(|result| match &result.token_result { GeneratedTokenResult::ContentToken(piece) => Some(piece.as_str()), _ => None, }) diff --git a/paddler_tests/tests/qwen36_internal_endpoint_with_thinking_enabled_emits_reasoning_tokens.rs b/paddler_tests/tests/qwen36_internal_endpoint_with_thinking_enabled_emits_reasoning_tokens.rs index 697e23a7..b98de00c 100644 --- a/paddler_tests/tests/qwen36_internal_endpoint_with_thinking_enabled_emits_reasoning_tokens.rs +++ b/paddler_tests/tests/qwen36_internal_endpoint_with_thinking_enabled_emits_reasoning_tokens.rs @@ -41,7 +41,7 @@ async fn qwen36_internal_endpoint_with_thinking_enabled_emits_reasoning_tokens() let reasoning_count = collected .token_results .iter() - .filter(|result| matches!(result, GeneratedTokenResult::ReasoningToken(_))) + .filter(|result| matches!(result.token_result, GeneratedTokenResult::ReasoningToken(_))) .count(); assert!( @@ -53,7 +53,7 @@ async fn qwen36_internal_endpoint_with_thinking_enabled_emits_reasoning_tokens() .token_results .last() .ok_or_else(|| anyhow::anyhow!("no token results received"))?; - let GeneratedTokenResult::Done(summary) = last else { + let GeneratedTokenResult::Done(summary) = &last.token_result else { anyhow::bail!("last result was not Done: {last:?}"); }; @@ -69,7 +69,7 @@ async fn qwen36_internal_endpoint_with_thinking_enabled_emits_reasoning_tokens() let reasoning_stream: String = collected .token_results .iter() - .filter_map(|result| match result { + .filter_map(|result| match &result.token_result { GeneratedTokenResult::ReasoningToken(piece) => Some(piece.as_str()), _ => None, }) @@ -77,7 +77,7 @@ async fn qwen36_internal_endpoint_with_thinking_enabled_emits_reasoning_tokens() let content_stream: String = collected .token_results .iter() - .filter_map(|result| match result { + .filter_map(|result| match &result.token_result { GeneratedTokenResult::ContentToken(piece) => Some(piece.as_str()), _ => None, }) diff --git a/paddler_tests/tests/qwen3_generates_tokens_from_conversation_history.rs b/paddler_tests/tests/qwen3_generates_tokens_from_conversation_history.rs index 05ec1561..4b27d60c 100644 --- a/paddler_tests/tests/qwen3_generates_tokens_from_conversation_history.rs +++ b/paddler_tests/tests/qwen3_generates_tokens_from_conversation_history.rs @@ -4,6 +4,7 @@ use anyhow::Result; use paddler_tests::collect_generated_tokens::collect_generated_tokens; use paddler_tests::inference_http_client::InferenceHttpClient; use paddler_tests::start_in_process_cluster_with_qwen3::start_in_process_cluster_with_qwen3; +use paddler_tests::token_result_with_producer::TokenResultWithProducer; use paddler_types::conversation_history::ConversationHistory; use paddler_types::conversation_message::ConversationMessage; use paddler_types::conversation_message_content::ConversationMessageContent; @@ -39,14 +40,17 @@ async fn qwen3_generates_tokens_from_conversation_history() -> Result<()> { let token_count = collected .token_results .iter() - .filter(|result| result.is_token()) + .filter(|result| result.token_result.is_token()) .count(); assert!(token_count > 0); assert!(token_count < 500, "EOG should stop generation early"); assert!(matches!( collected.token_results.last(), - Some(GeneratedTokenResult::Done(_)) + Some(TokenResultWithProducer { + token_result: GeneratedTokenResult::Done(_), + .. + }) )); cluster.shutdown().await?; diff --git a/paddler_tests/tests/qwen3_generates_tokens_from_raw_prompt.rs b/paddler_tests/tests/qwen3_generates_tokens_from_raw_prompt.rs index eef50f8e..eccbd7a9 100644 --- a/paddler_tests/tests/qwen3_generates_tokens_from_raw_prompt.rs +++ b/paddler_tests/tests/qwen3_generates_tokens_from_raw_prompt.rs @@ -4,6 +4,7 @@ use anyhow::Result; use paddler_tests::collect_generated_tokens::collect_generated_tokens; use paddler_tests::inference_http_client::InferenceHttpClient; use paddler_tests::start_in_process_cluster_with_qwen3::start_in_process_cluster_with_qwen3; +use paddler_tests::token_result_with_producer::TokenResultWithProducer; use paddler_types::generated_token_result::GeneratedTokenResult; use paddler_types::request_params::ContinueFromRawPromptParams; use reqwest::Client; @@ -31,13 +32,16 @@ async fn qwen3_generates_tokens_from_raw_prompt() -> Result<()> { let token_count = collected .token_results .iter() - .filter(|result| result.is_token()) + .filter(|result| result.token_result.is_token()) .count(); assert!(token_count > 0); assert!(matches!( collected.token_results.last(), - Some(GeneratedTokenResult::Done(_)) + Some(TokenResultWithProducer { + token_result: GeneratedTokenResult::Done(_), + .. + }) )); cluster.shutdown().await?; diff --git a/paddler_tests/tests/qwen3_grammar_with_thinking_returns_incompatible_error.rs b/paddler_tests/tests/qwen3_grammar_with_thinking_returns_incompatible_error.rs index 05c48a8f..e941af2d 100644 --- a/paddler_tests/tests/qwen3_grammar_with_thinking_returns_incompatible_error.rs +++ b/paddler_tests/tests/qwen3_grammar_with_thinking_returns_incompatible_error.rs @@ -42,7 +42,7 @@ async fn qwen3_grammar_with_thinking_returns_incompatible_error() -> Result<()> if let Ok(collected) = collected { assert!( collected.token_results.iter().any(|result| matches!( - result, + result.token_result, GeneratedTokenResult::GrammarIncompatibleWithThinking(_) )), "expected GrammarIncompatibleWithThinking, got: {:?}", diff --git a/paddler_tests/tests/qwen3_internal_endpoint_concurrent_requests_independent_usage.rs b/paddler_tests/tests/qwen3_internal_endpoint_concurrent_requests_independent_usage.rs index 6f8154da..7322ee23 100644 --- a/paddler_tests/tests/qwen3_internal_endpoint_concurrent_requests_independent_usage.rs +++ b/paddler_tests/tests/qwen3_internal_endpoint_concurrent_requests_independent_usage.rs @@ -48,7 +48,7 @@ async fn qwen3_internal_endpoint_concurrent_requests_keep_independent_usage() -> .token_results .last() .ok_or_else(|| anyhow::anyhow!("no token results received"))?; - match last { + match &last.token_result { GeneratedTokenResult::Done(summary) => { Ok::(*summary) } diff --git a/paddler_tests/tests/qwen3_internal_endpoint_emits_tool_call_parsed_event.rs b/paddler_tests/tests/qwen3_internal_endpoint_emits_tool_call_parsed_event.rs index be4a1c50..91101840 100644 --- a/paddler_tests/tests/qwen3_internal_endpoint_emits_tool_call_parsed_event.rs +++ b/paddler_tests/tests/qwen3_internal_endpoint_emits_tool_call_parsed_event.rs @@ -66,7 +66,7 @@ async fn qwen3_internal_endpoint_emits_tool_call_parsed_event() -> Result<()> { let parsed_events: Vec<&Vec> = collected .token_results .iter() - .filter_map(|event| match event { + .filter_map(|event| match &event.token_result { GeneratedTokenResult::ToolCallParsed(parsed) => Some(parsed), _ => None, }) diff --git a/paddler_tests/tests/qwen3_internal_endpoint_emits_tool_call_tokens.rs b/paddler_tests/tests/qwen3_internal_endpoint_emits_tool_call_tokens.rs index e363138b..69561133 100644 --- a/paddler_tests/tests/qwen3_internal_endpoint_emits_tool_call_tokens.rs +++ b/paddler_tests/tests/qwen3_internal_endpoint_emits_tool_call_tokens.rs @@ -66,19 +66,19 @@ async fn qwen3_internal_endpoint_emits_tool_call_tokens() -> Result<()> { let tool_call_count = collected .token_results .iter() - .filter(|result| matches!(result, GeneratedTokenResult::ToolCallToken(_))) + .filter(|result| matches!(result.token_result, GeneratedTokenResult::ToolCallToken(_))) .count(); let content_count = collected .token_results .iter() - .filter(|result| matches!(result, GeneratedTokenResult::ContentToken(_))) + .filter(|result| matches!(result.token_result, GeneratedTokenResult::ContentToken(_))) .count(); let last = collected .token_results .last() .ok_or_else(|| anyhow::anyhow!("no token results received"))?; - let GeneratedTokenResult::Done(summary) = last else { + let GeneratedTokenResult::Done(summary) = &last.token_result else { anyhow::bail!("last result was not Done: {last:?}"); }; diff --git a/paddler_tests/tests/qwen3_internal_endpoint_max_tokens_usage_matches.rs b/paddler_tests/tests/qwen3_internal_endpoint_max_tokens_usage_matches.rs index 6ff3ac3a..aead2574 100644 --- a/paddler_tests/tests/qwen3_internal_endpoint_max_tokens_usage_matches.rs +++ b/paddler_tests/tests/qwen3_internal_endpoint_max_tokens_usage_matches.rs @@ -41,14 +41,14 @@ async fn qwen3_internal_endpoint_max_tokens_usage_matches_streamed_count() -> Re let streamed_token_count = collected .token_results .iter() - .filter(|result| result.is_token()) + .filter(|result| result.token_result.is_token()) .count() as u64; let last = collected .token_results .last() .ok_or_else(|| anyhow::anyhow!("no token results received"))?; - let GeneratedTokenResult::Done(summary) = last else { + let GeneratedTokenResult::Done(summary) = &last.token_result else { anyhow::bail!("last result was not Done: {last:?}"); }; diff --git a/paddler_tests/tests/qwen3_internal_endpoint_pure_content_usage.rs b/paddler_tests/tests/qwen3_internal_endpoint_pure_content_usage.rs index 4332639a..76094d7f 100644 --- a/paddler_tests/tests/qwen3_internal_endpoint_pure_content_usage.rs +++ b/paddler_tests/tests/qwen3_internal_endpoint_pure_content_usage.rs @@ -40,7 +40,7 @@ async fn qwen3_internal_endpoint_pure_content_usage_breakdown() -> Result<()> { .token_results .last() .ok_or_else(|| anyhow::anyhow!("no token results received"))?; - let GeneratedTokenResult::Done(summary) = last else { + let GeneratedTokenResult::Done(summary) = &last.token_result else { anyhow::bail!("last result was not Done: {last:?}"); }; diff --git a/paddler_tests/tests/qwen3_internal_endpoint_tools_without_parse_flag_emit_only_raw_tokens.rs b/paddler_tests/tests/qwen3_internal_endpoint_tools_without_parse_flag_emit_only_raw_tokens.rs index ed74867c..cffccaf2 100644 --- a/paddler_tests/tests/qwen3_internal_endpoint_tools_without_parse_flag_emit_only_raw_tokens.rs +++ b/paddler_tests/tests/qwen3_internal_endpoint_tools_without_parse_flag_emit_only_raw_tokens.rs @@ -64,7 +64,7 @@ async fn qwen3_internal_endpoint_tools_without_parse_flag_emit_only_raw_tokens() let collected = collect_generated_tokens(stream).await?; for event in &collected.token_results { - match event { + match &event.token_result { GeneratedTokenResult::ToolCallParsed(_) | GeneratedTokenResult::ToolCallParseFailed(_) | GeneratedTokenResult::ToolSchemaInvalid(_) @@ -81,7 +81,7 @@ async fn qwen3_internal_endpoint_tools_without_parse_flag_emit_only_raw_tokens() .token_results .last() .ok_or_else(|| anyhow::anyhow!("no token results received"))?; - let GeneratedTokenResult::Done(_) = last else { + let GeneratedTokenResult::Done(_) = &last.token_result else { anyhow::bail!("last result was not Done: {last:?}"); }; diff --git a/paddler_tests/tests/qwen3_internal_endpoint_with_thinking_disabled_emits_no_reasoning_tokens.rs b/paddler_tests/tests/qwen3_internal_endpoint_with_thinking_disabled_emits_no_reasoning_tokens.rs index 785ff6e4..c21b1f19 100644 --- a/paddler_tests/tests/qwen3_internal_endpoint_with_thinking_disabled_emits_no_reasoning_tokens.rs +++ b/paddler_tests/tests/qwen3_internal_endpoint_with_thinking_disabled_emits_no_reasoning_tokens.rs @@ -39,12 +39,12 @@ async fn qwen3_internal_endpoint_with_thinking_disabled_emits_no_reasoning_token let reasoning_count = collected .token_results .iter() - .filter(|result| matches!(result, GeneratedTokenResult::ReasoningToken(_))) + .filter(|result| matches!(result.token_result, GeneratedTokenResult::ReasoningToken(_))) .count(); let content_count = collected .token_results .iter() - .filter(|result| matches!(result, GeneratedTokenResult::ContentToken(_))) + .filter(|result| matches!(result.token_result, GeneratedTokenResult::ContentToken(_))) .count(); assert_eq!( @@ -57,7 +57,7 @@ async fn qwen3_internal_endpoint_with_thinking_disabled_emits_no_reasoning_token .token_results .last() .ok_or_else(|| anyhow::anyhow!("no token results received"))?; - let GeneratedTokenResult::Done(summary) = last else { + let GeneratedTokenResult::Done(summary) = &last.token_result else { anyhow::bail!("last result was not Done: {last:?}"); }; diff --git a/paddler_tests/tests/qwen3_internal_endpoint_with_thinking_enabled_emits_reasoning_tokens.rs b/paddler_tests/tests/qwen3_internal_endpoint_with_thinking_enabled_emits_reasoning_tokens.rs index d9010bb5..c7c97f1e 100644 --- a/paddler_tests/tests/qwen3_internal_endpoint_with_thinking_enabled_emits_reasoning_tokens.rs +++ b/paddler_tests/tests/qwen3_internal_endpoint_with_thinking_enabled_emits_reasoning_tokens.rs @@ -41,7 +41,7 @@ async fn qwen3_internal_endpoint_with_thinking_enabled_emits_reasoning_tokens() let reasoning_count = collected .token_results .iter() - .filter(|result| matches!(result, GeneratedTokenResult::ReasoningToken(_))) + .filter(|result| matches!(result.token_result, GeneratedTokenResult::ReasoningToken(_))) .count(); assert!( @@ -53,7 +53,7 @@ async fn qwen3_internal_endpoint_with_thinking_enabled_emits_reasoning_tokens() .token_results .last() .ok_or_else(|| anyhow::anyhow!("no token results received"))?; - let GeneratedTokenResult::Done(summary) = last else { + let GeneratedTokenResult::Done(summary) = &last.token_result else { anyhow::bail!("last result was not Done: {last:?}"); }; diff --git a/paddler_tests/tests/qwen3_without_grammar_generates_unconstrained_output.rs b/paddler_tests/tests/qwen3_without_grammar_generates_unconstrained_output.rs index 3d4e6a80..46cc7b44 100644 --- a/paddler_tests/tests/qwen3_without_grammar_generates_unconstrained_output.rs +++ b/paddler_tests/tests/qwen3_without_grammar_generates_unconstrained_output.rs @@ -28,7 +28,7 @@ async fn qwen3_without_grammar_generates_unconstrained_output() -> Result<()> { let token_count = collected .token_results .iter() - .filter(|result| result.is_token()) + .filter(|result| result.token_result.is_token()) .count(); assert!(token_count > 0); diff --git a/paddler_tests/tests/smolvlm2_generates_tokens_from_image_input.rs b/paddler_tests/tests/smolvlm2_generates_tokens_from_image_input.rs index 29750426..bcfe7d01 100644 --- a/paddler_tests/tests/smolvlm2_generates_tokens_from_image_input.rs +++ b/paddler_tests/tests/smolvlm2_generates_tokens_from_image_input.rs @@ -5,6 +5,7 @@ use paddler_tests::collect_generated_tokens::collect_generated_tokens; use paddler_tests::inference_http_client::InferenceHttpClient; use paddler_tests::load_test_image_data_uri::load_test_image_data_uri; use paddler_tests::start_in_process_cluster_with_smolvlm2::start_in_process_cluster_with_smolvlm2; +use paddler_tests::token_result_with_producer::TokenResultWithProducer; use paddler_types::conversation_history::ConversationHistory; use paddler_types::conversation_message::ConversationMessage; use paddler_types::conversation_message_content::ConversationMessageContent; @@ -55,13 +56,16 @@ async fn smolvlm2_generates_tokens_from_image_input() -> Result<()> { let token_count = collected .token_results .iter() - .filter(|result| result.is_token()) + .filter(|result| result.token_result.is_token()) .count(); assert!(token_count > 0); assert!(matches!( collected.token_results.last(), - Some(GeneratedTokenResult::Done(_)) + Some(TokenResultWithProducer { + token_result: GeneratedTokenResult::Done(_), + .. + }) )); cluster.shutdown().await?; diff --git a/paddler_types/src/jsonrpc/response_envelope.rs b/paddler_types/src/jsonrpc/response_envelope.rs index 27b3458f..00818344 100644 --- a/paddler_types/src/jsonrpc/response_envelope.rs +++ b/paddler_types/src/jsonrpc/response_envelope.rs @@ -4,6 +4,7 @@ use serde::Serialize; #[derive(Debug, Deserialize, Serialize)] #[serde(deny_unknown_fields)] pub struct ResponseEnvelope { + pub generated_by: Option, pub request_id: String, pub response: TResponse, } From 7ff51d8868e62631293537392a59639a8fd2c319 Mon Sep 17 00:00:00 2001 From: Mateusz Charytoniuk Date: Sun, 10 May 2026 06:53:22 +0200 Subject: [PATCH 29/51] Surface UnrecognizedToolCallFormat through wire types and clients; add SSE shutdown and dispatch candidate tests --- paddler/src/atomic_value.rs | 44 ------- paddler/src/balancer/agent_controller_pool.rs | 64 +++++++--- .../http_route/post_chat_completions.rs | 73 ++++++++++++ paddler/src/balancer/dispatch_candidate.rs | 8 ++ paddler/src/balancer/mod.rs | 1 + paddler/src/tool_call_event.rs | 36 ++++++ paddler/src/tool_call_pipeline.rs | 31 ++++- paddler_client_cli/src/streaming_response.rs | 5 + .../InferenceServiceGenerateTokensResponse.ts | 55 +++++++++ ...renceServiceGenerateTokensResponse.test.ts | 32 +++++ .../paddler_client/inference_message.py | 16 +++ .../paddler_client/raw_tool_call_tokens.py | 17 +++ .../tests/test_inference_message.py | 45 +++++++ ...r_pool_re_selects_after_contended_claim.rs | 68 +++++++++++ ..._format_when_template_is_not_registered.rs | 112 ++++++++++++++++++ ..._subscriber_completes_within_one_second.rs | 43 +++++++ paddler_types/src/generated_token_result.rs | 16 +++ paddler_types/src/lib.rs | 1 + paddler_types/src/raw_tool_call_tokens.rs | 25 ++++ 19 files changed, 629 insertions(+), 63 deletions(-) create mode 100644 paddler/src/balancer/dispatch_candidate.rs create mode 100644 paddler_client_python/paddler_client/raw_tool_call_tokens.py create mode 100644 paddler_tests/tests/agent_controller_pool_re_selects_after_contended_claim.rs create mode 100644 paddler_tests/tests/agent_pipeline_recognizes_duck_typed_tool_call_format_when_template_is_not_registered.rs create mode 100644 paddler_tests/tests/balancer_in_process_shutdown_with_open_sse_subscriber_completes_within_one_second.rs create mode 100644 paddler_types/src/raw_tool_call_tokens.rs diff --git a/paddler/src/atomic_value.rs b/paddler/src/atomic_value.rs index 4b2a9ffa..9f0202d7 100644 --- a/paddler/src/atomic_value.rs +++ b/paddler/src/atomic_value.rs @@ -77,20 +77,6 @@ impl AtomicValue { true } } - - pub fn try_increment_below(&self, limit: i32) -> bool { - loop { - let current = self.get(); - - if current >= limit { - return false; - } - - if self.compare_and_swap(current, current + 1) { - return true; - } - } - } } impl AtomicValue { @@ -123,33 +109,3 @@ impl AtomicValue { } } } - -#[cfg(test)] -mod tests { - use super::AtomicValue; - use std::sync::atomic::AtomicI32; - - #[test] - fn try_increment_below_increments_when_below_limit() { - let value = AtomicValue::::new(0); - - assert!(value.try_increment_below(1)); - assert_eq!(value.get(), 1); - } - - #[test] - fn try_increment_below_refuses_at_limit() { - let value = AtomicValue::::new(1); - - assert!(!value.try_increment_below(1)); - assert_eq!(value.get(), 1); - } - - #[test] - fn try_increment_below_refuses_above_limit() { - let value = AtomicValue::::new(5); - - assert!(!value.try_increment_below(3)); - assert_eq!(value.get(), 5); - } -} diff --git a/paddler/src/balancer/agent_controller_pool.rs b/paddler/src/balancer/agent_controller_pool.rs index 1c47a478..8c4188ba 100644 --- a/paddler/src/balancer/agent_controller_pool.rs +++ b/paddler/src/balancer/agent_controller_pool.rs @@ -11,6 +11,7 @@ use tokio::sync::watch; use super::agent_controller::AgentController; use super::agent_controller_pool_total_slots::AgentControllerPoolTotalSlots; use crate::balancer::agent_controller_slot_guard::AgentControllerSlotGuard; +use crate::balancer::dispatch_candidate::DispatchCandidate; use crate::balancer::dispatched_agent::DispatchedAgent; use crate::produces_snapshot::ProducesSnapshot; use crate::sets_desired_state::SetsDesiredState; @@ -23,29 +24,60 @@ pub struct AgentControllerPool { impl AgentControllerPool { #[must_use] - pub fn take_least_busy_agent_controller(&self) -> Option { - let mut candidates: Vec> = self - .agents - .iter() - .map(|entry| entry.value().clone()) - .collect(); + pub fn select_least_busy_with_capacity(&self) -> Option { + let mut best: Option = None; - candidates.sort_by_key(|agent| agent.slots_processing.get()); + for entry in &self.agents { + let agent_controller = entry.value().clone(); + let snapshot = agent_controller.slots_processing.get(); - for agent_controller in candidates { - let limit = agent_controller.slots_total.get(); + if snapshot >= agent_controller.slots_total.get() { + continue; + } - if agent_controller.slots_processing.try_increment_below(limit) { - self.update_tx.send_replace(()); + best = Some(match best { + Some(current) if current.snapshot <= snapshot => current, + _ => DispatchCandidate { + agent_controller, + snapshot, + }, + }); + } - let slot_guard = - AgentControllerSlotGuard::new(agent_controller.clone(), self.update_tx.clone()); + best + } - return Some(DispatchedAgent::new(agent_controller, slot_guard)); - } + pub fn try_claim( + &self, + candidate: DispatchCandidate, + ) -> Result { + if candidate + .agent_controller + .slots_processing + .compare_and_swap(candidate.snapshot, candidate.snapshot + 1) + { + self.update_tx.send_replace(()); + + let slot_guard = AgentControllerSlotGuard::new( + candidate.agent_controller.clone(), + self.update_tx.clone(), + ); + + Ok(DispatchedAgent::new(candidate.agent_controller, slot_guard)) + } else { + Err(candidate) } + } + + #[must_use] + pub fn take_least_busy_agent_controller(&self) -> Option { + loop { + let candidate = self.select_least_busy_with_capacity()?; - None + if let Ok(dispatched) = self.try_claim(candidate) { + return Some(dispatched); + } + } } #[must_use] diff --git a/paddler/src/balancer/compatibility/openai_service/http_route/post_chat_completions.rs b/paddler/src/balancer/compatibility/openai_service/http_route/post_chat_completions.rs index 36d94eac..cd49387b 100644 --- a/paddler/src/balancer/compatibility/openai_service/http_route/post_chat_completions.rs +++ b/paddler/src/balancer/compatibility/openai_service/http_route/post_chat_completions.rs @@ -24,6 +24,7 @@ use paddler_types::jsonrpc::ResponseEnvelope; use llama_cpp_bindings::ParsedToolCall; use llama_cpp_bindings::TokenUsage; use llama_cpp_bindings::ToolCallArguments; +use paddler_types::raw_tool_call_tokens::RawToolCallTokens; use paddler_types::request_params::continue_from_conversation_history_params::ContinueFromConversationHistoryParams; use paddler_types::request_params::continue_from_conversation_history_params::tool::Tool; use paddler_types::request_params::continue_from_conversation_history_params::tool::tool_params::function_call::parameters_schema::raw_parameters_schema::RawParametersSchema; @@ -87,6 +88,14 @@ fn validation_failure_message(errors: &[String]) -> String { .unwrap_or_else(|| "tool call failed validation".to_owned()) } +fn unrecognized_tool_call_format_message(raw: &RawToolCallTokens) -> String { + format!( + "model produced output the parser did not recognise as any registered tool-call format; \ + FFI error: {}; raw text: {}", + raw.ffi_error_message, raw.text, + ) +} + fn arguments_to_openai_string(arguments: &ToolCallArguments) -> Result { match arguments { ToolCallArguments::ValidJson(value) => { @@ -411,6 +420,15 @@ impl TransformsOutgoingMessage for OpenAIStreamingResponseTransformer { }) => Ok(vec![server_error_chunk(&validation_failure_message( &errors, ))]), + OutgoingMessage::Response(ResponseEnvelope { + response: + OutgoingResponse::GeneratedToken(GeneratedTokenResult::UnrecognizedToolCallFormat( + raw, + )), + .. + }) => Ok(vec![server_error_chunk( + &unrecognized_tool_call_format_message(&raw), + )]), OutgoingMessage::Response(ResponseEnvelope { request_id, response: OutgoingResponse::GeneratedToken(GeneratedTokenResult::Done(summary)), @@ -581,6 +599,15 @@ impl TransformsOutgoingMessage for OpenAINonStreamingResponseTransformer { }) => Ok(vec![server_error_chunk(&validation_failure_message( &errors, ))]), + OutgoingMessage::Response(ResponseEnvelope { + response: + OutgoingResponse::GeneratedToken(GeneratedTokenResult::UnrecognizedToolCallFormat( + raw, + )), + .. + }) => Ok(vec![server_error_chunk( + &unrecognized_tool_call_format_message(&raw), + )]), OutgoingMessage::Response(ResponseEnvelope { request_id, response: OutgoingResponse::GeneratedToken(GeneratedTokenResult::Done(summary)), @@ -1013,6 +1040,29 @@ mod tests { Ok(()) } + #[actix_web::test] + async fn streaming_unrecognized_tool_call_format_emits_server_error() -> Result<()> { + let transformer = streaming_transformer(false); + + let chunks = transformer + .transform(make_token_message( + GeneratedTokenResult::UnrecognizedToolCallFormat( + paddler_types::raw_tool_call_tokens::RawToolCallTokens { + text: "blah".to_owned(), + ffi_error_message: "common_chat_parse failed: no parser".to_owned(), + }, + ), + )) + .await?; + + assert_eq!(chunks.len(), 1); + assert_error_contains(&chunks[0], "common_chat_parse failed: no parser")?; + assert_error_contains(&chunks[0], "blah")?; + assert_error_contains(&chunks[0], "server_error")?; + + Ok(()) + } + #[actix_web::test] async fn streaming_error_message_returns_error_variant() -> Result<()> { let transformer = streaming_transformer(false); @@ -1241,6 +1291,29 @@ mod tests { Ok(()) } + #[actix_web::test] + async fn non_streaming_unrecognized_tool_call_format_emits_server_error() -> Result<()> { + let transformer = non_streaming_transformer(); + + let chunks = transformer + .transform(make_token_message( + GeneratedTokenResult::UnrecognizedToolCallFormat( + paddler_types::raw_tool_call_tokens::RawToolCallTokens { + text: "blah".to_owned(), + ffi_error_message: "common_chat_parse failed: no parser".to_owned(), + }, + ), + )) + .await?; + + assert_eq!(chunks.len(), 1); + assert_error_contains(&chunks[0], "common_chat_parse failed: no parser")?; + assert_error_contains(&chunks[0], "blah")?; + assert_error_contains(&chunks[0], "server_error")?; + + Ok(()) + } + #[actix_web::test] async fn non_streaming_error_message_returns_error_variant() -> Result<()> { let transformer = non_streaming_transformer(); diff --git a/paddler/src/balancer/dispatch_candidate.rs b/paddler/src/balancer/dispatch_candidate.rs new file mode 100644 index 00000000..7d8785cc --- /dev/null +++ b/paddler/src/balancer/dispatch_candidate.rs @@ -0,0 +1,8 @@ +use std::sync::Arc; + +use crate::balancer::agent_controller::AgentController; + +pub struct DispatchCandidate { + pub agent_controller: Arc, + pub snapshot: i32, +} diff --git a/paddler/src/balancer/mod.rs b/paddler/src/balancer/mod.rs index 79047014..140ae13e 100644 --- a/paddler/src/balancer/mod.rs +++ b/paddler/src/balancer/mod.rs @@ -11,6 +11,7 @@ pub mod chat_template_override_sender_collection; mod chunk_forwarding_session_controller; pub mod compatibility; mod controls_manages_senders_endpoint; +pub mod dispatch_candidate; pub mod dispatched_agent; pub mod embedding_sender_collection; pub mod generate_tokens_sender_collection; diff --git a/paddler/src/tool_call_event.rs b/paddler/src/tool_call_event.rs index bb8a0156..54a692c1 100644 --- a/paddler/src/tool_call_event.rs +++ b/paddler/src/tool_call_event.rs @@ -1,5 +1,6 @@ use llama_cpp_bindings::ParsedToolCall; use paddler_types::generated_token_result::GeneratedTokenResult; +use paddler_types::raw_tool_call_tokens::RawToolCallTokens; use crate::tool_call_pipeline_error::ToolCallPipelineError; use crate::tool_call_validation_error::ToolCallValidationError; @@ -10,6 +11,7 @@ pub enum ToolCallEvent { Resolved(Vec), ParseFailed(ToolCallPipelineError), ValidationFailed(Vec), + UnrecognizedFormat(RawToolCallTokens), } impl ToolCallEvent { @@ -38,6 +40,9 @@ impl ToolCallEvent { Self::ValidationFailed(errors) => Some(GeneratedTokenResult::ToolCallValidationFailed( errors.into_iter().map(|err| err.to_string()).collect(), )), + Self::UnrecognizedFormat(raw) => { + Some(GeneratedTokenResult::UnrecognizedToolCallFormat(raw)) + } Self::Pending => None, } } @@ -50,6 +55,7 @@ mod tests { use llama_cpp_bindings::ParsedToolCall; use llama_cpp_bindings::ToolCallArguments; use paddler_types::generated_token_result::GeneratedTokenResult; + use paddler_types::raw_tool_call_tokens::RawToolCallTokens; use serde_json::json; use super::ToolCallEvent; @@ -146,4 +152,34 @@ mod tests { other => bail!("expected ToolCallValidationFailed mentioning 'missing', got {other:?}"), } } + + #[test] + fn unrecognized_format_classifies_as_neither_resolved_nor_failure_nor_pending() { + let event = ToolCallEvent::UnrecognizedFormat(RawToolCallTokens { + text: "raw".to_owned(), + ffi_error_message: "bailed".to_owned(), + }); + + assert!(!event.is_pending()); + assert!(!event.is_resolved()); + assert!(!event.is_failure()); + } + + #[test] + fn unrecognized_format_converts_to_unrecognized_tool_call_format_preserving_payload() + -> Result<()> { + let event = ToolCallEvent::UnrecognizedFormat(RawToolCallTokens { + text: "raw output".to_owned(), + ffi_error_message: "parser bailed".to_owned(), + }); + + match event.into_generated_token_result() { + Some(GeneratedTokenResult::UnrecognizedToolCallFormat(raw)) => { + assert_eq!(raw.text, "raw output"); + assert_eq!(raw.ffi_error_message, "parser bailed"); + Ok(()) + } + other => bail!("expected UnrecognizedToolCallFormat preserving payload, got {other:?}"), + } + } } diff --git a/paddler/src/tool_call_pipeline.rs b/paddler/src/tool_call_pipeline.rs index 6499b110..016de45f 100644 --- a/paddler/src/tool_call_pipeline.rs +++ b/paddler/src/tool_call_pipeline.rs @@ -1,8 +1,11 @@ use std::sync::Arc; +use llama_cpp_bindings::ChatMessageParseOutcome; use llama_cpp_bindings::ParsedToolCall; +use llama_cpp_bindings::RawChatMessage; use llama_cpp_bindings::model::LlamaModel; use paddler_types::generated_token_result::GeneratedTokenResult; +use paddler_types::raw_tool_call_tokens::RawToolCallTokens; use crate::tool_call_buffer::ToolCallBuffer; use crate::tool_call_event::ToolCallEvent; @@ -46,7 +49,17 @@ impl ToolCallPipeline { .model .parse_chat_message(&self.tools_json, &input, false) { - Ok(parsed) => self.validate_resolved(parsed.tool_calls), + Ok(ChatMessageParseOutcome::Recognized(parsed)) => { + self.validate_resolved(parsed.tool_calls) + } + Ok(ChatMessageParseOutcome::Unrecognized(RawChatMessage { + text, + ffi_error_message, + .. + })) => ToolCallEvent::UnrecognizedFormat(RawToolCallTokens { + text, + ffi_error_message, + }), Err(err) => ToolCallEvent::ParseFailed(ToolCallPipelineError::Bindings(err)), } } @@ -63,8 +76,20 @@ impl ToolCallPipeline { } match self.model.parse_chat_message(&self.tools_json, input, true) { - Ok(parsed) if parsed.tool_calls.is_empty() => ToolCallEvent::Pending, - Ok(parsed) => self.validate_resolved(parsed.tool_calls), + Ok(ChatMessageParseOutcome::Recognized(parsed)) if parsed.tool_calls.is_empty() => { + ToolCallEvent::Pending + } + Ok(ChatMessageParseOutcome::Recognized(parsed)) => { + self.validate_resolved(parsed.tool_calls) + } + Ok(ChatMessageParseOutcome::Unrecognized(RawChatMessage { + text, + ffi_error_message, + .. + })) => ToolCallEvent::UnrecognizedFormat(RawToolCallTokens { + text, + ffi_error_message, + }), Err(err) => ToolCallEvent::ParseFailed(ToolCallPipelineError::Bindings(err)), } } diff --git a/paddler_client_cli/src/streaming_response.rs b/paddler_client_cli/src/streaming_response.rs index 48007bf8..42a09067 100644 --- a/paddler_client_cli/src/streaming_response.rs +++ b/paddler_client_cli/src/streaming_response.rs @@ -3,6 +3,7 @@ use paddler_types::generated_token_result::GeneratedTokenResult; use paddler_types::generation_summary::GenerationSummary; use paddler_types::inference_client::Message; use paddler_types::inference_client::Response; +use paddler_types::raw_tool_call_tokens::RawToolCallTokens; use crate::stop_reason::StopReason; @@ -13,6 +14,7 @@ pub struct StreamingResponse { pub tool_call_tokens: Vec, pub tool_calls: Vec, pub undetermined: Vec, + pub unrecognized_tool_call_format: Vec, pub summary: Option, pub stop_reason: Option, } @@ -99,6 +101,9 @@ impl StreamingResponse { GeneratedTokenResult::ToolSchemaInvalid(detail) => { self.stop_reason = Some(StopReason::ToolSchemaInvalid(detail)); } + GeneratedTokenResult::UnrecognizedToolCallFormat(raw) => { + self.unrecognized_tool_call_format.push(raw); + } } } } diff --git a/paddler_client_javascript/src/schemas/InferenceServiceGenerateTokensResponse.ts b/paddler_client_javascript/src/schemas/InferenceServiceGenerateTokensResponse.ts index 1fe573af..be7f62ae 100644 --- a/paddler_client_javascript/src/schemas/InferenceServiceGenerateTokensResponse.ts +++ b/paddler_client_javascript/src/schemas/InferenceServiceGenerateTokensResponse.ts @@ -23,6 +23,11 @@ const GenerationSummarySchema = z.object({ usage: TokenUsageSchema, }); +const RawToolCallTokensSchema = z.object({ + text: z.string(), + ffi_error_message: z.string(), +}); + const GeneratedTokenResultSchema = z.union([ z.object({ ContentToken: z.string() }), z.object({ ReasoningToken: z.string() }), @@ -41,6 +46,7 @@ const GeneratedTokenResultSchema = z.union([ z.object({ ToolCallParseFailed: z.string() }), z.object({ ToolCallValidationFailed: z.array(z.string()) }), z.object({ ToolCallValidatorBuildFailed: z.string() }), + z.object({ UnrecognizedToolCallFormat: RawToolCallTokensSchema }), ]); type Normalised = @@ -49,6 +55,7 @@ type Normalised = error: null; generated_by: string | null; ok: true; + rawToolCallTokens: null; request_id: string; summary: z.infer; token: null; @@ -60,6 +67,7 @@ type Normalised = error: null; generated_by: string | null; ok: true; + rawToolCallTokens: null; request_id: string; summary: null; token: string; @@ -71,17 +79,31 @@ type Normalised = error: null; generated_by: string | null; ok: true; + rawToolCallTokens: null; request_id: string; summary: null; token: null; tokenKind: null; toolCalls: ReadonlyArray>; } + | { + done: false; + error: null; + generated_by: string | null; + ok: true; + rawToolCallTokens: z.infer; + request_id: string; + summary: null; + token: null; + tokenKind: null; + toolCalls: null; + } | { done: true; error: { code: number; description: string }; generated_by: string | null; ok: false; + rawToolCallTokens: null; request_id: string; summary: null; token: null; @@ -93,6 +115,7 @@ type Normalised = error: { code: number; description: string }; generated_by: string | null; ok: false; + rawToolCallTokens: null; request_id: string; summary: null; token: null; @@ -111,6 +134,7 @@ function terminalError( error: Object.freeze({ code, description }), generated_by, ok: false, + rawToolCallTokens: null, request_id, summary: null, token: null, @@ -130,6 +154,7 @@ function nonTerminalError( error: Object.freeze({ code, description }), generated_by, ok: false, + rawToolCallTokens: null, request_id, summary: null, token: null, @@ -149,6 +174,7 @@ function streamingToken( error: null, generated_by, ok: true, + rawToolCallTokens: null, request_id, summary: null, token, @@ -157,6 +183,25 @@ function streamingToken( }); } +function unrecognizedToolCallFormat( + request_id: string, + generated_by: string | null, + raw: z.infer, +): Normalised { + return Object.freeze({ + done: false, + error: null, + generated_by, + ok: true, + rawToolCallTokens: Object.freeze(raw), + request_id, + summary: null, + token: null, + tokenKind: null, + toolCalls: null, + }); +} + export const InferenceServiceGenerateTokensResponseSchema = z .union([ z.object({ @@ -219,6 +264,7 @@ export const InferenceServiceGenerateTokensResponseSchema = z error: null, generated_by, ok: true, + rawToolCallTokens: null, request_id, summary: variant.Done, token: null, @@ -233,6 +279,7 @@ export const InferenceServiceGenerateTokensResponseSchema = z error: null, generated_by, ok: true, + rawToolCallTokens: null, request_id, summary: null, token: null, @@ -241,6 +288,14 @@ export const InferenceServiceGenerateTokensResponseSchema = z }); } + if ("UnrecognizedToolCallFormat" in variant) { + return unrecognizedToolCallFormat( + request_id, + generated_by, + variant.UnrecognizedToolCallFormat, + ); + } + if ("ToolCallParseFailed" in variant) { return nonTerminalError(request_id, generated_by, 422, variant.ToolCallParseFailed); } diff --git a/paddler_client_javascript/tests/schemas/InferenceServiceGenerateTokensResponse.test.ts b/paddler_client_javascript/tests/schemas/InferenceServiceGenerateTokensResponse.test.ts index 333b8759..c912cbd0 100644 --- a/paddler_client_javascript/tests/schemas/InferenceServiceGenerateTokensResponse.test.ts +++ b/paddler_client_javascript/tests/schemas/InferenceServiceGenerateTokensResponse.test.ts @@ -5,6 +5,7 @@ import { InferenceServiceGenerateTokensResponseSchema } from "../../src/schemas/ test("ContentToken normalises into a streaming token with content kind", function (t) { const parsed = InferenceServiceGenerateTokensResponseSchema.parse({ Response: { + generated_by: null, request_id: "req-1", response: { GeneratedToken: { ContentToken: "Hello" } }, }, @@ -20,6 +21,7 @@ test("ContentToken normalises into a streaming token with content kind", functio test("ReasoningToken maps to reasoning kind", function (t) { const parsed = InferenceServiceGenerateTokensResponseSchema.parse({ Response: { + generated_by: null, request_id: "req-2", response: { GeneratedToken: { ReasoningToken: "thinking..." } }, }, @@ -32,6 +34,7 @@ test("ReasoningToken maps to reasoning kind", function (t) { test("Done normalises with the full usage summary", function (t) { const parsed = InferenceServiceGenerateTokensResponseSchema.parse({ Response: { + generated_by: null, request_id: "req-3", response: { GeneratedToken: { @@ -60,6 +63,7 @@ test("Done normalises with the full usage summary", function (t) { test("ToolCallValidatorBuildFailed normalises to a terminal error", function (t) { const parsed = InferenceServiceGenerateTokensResponseSchema.parse({ Response: { + generated_by: null, request_id: "req-4", response: { GeneratedToken: { @@ -84,3 +88,31 @@ test("Top-level Error envelope normalises to terminal error", function (t) { t.is(parsed.done, true); t.deepEqual(parsed.error, { code: 500, description: "boom" }); }); + +test("UnrecognizedToolCallFormat preserves text and FFI error message", function (t) { + const parsed = InferenceServiceGenerateTokensResponseSchema.parse({ + Response: { + generated_by: null, + request_id: "req-6", + response: { + GeneratedToken: { + UnrecognizedToolCallFormat: { + text: "raw", + ffi_error_message: "common_chat_parse failed: no parser", + }, + }, + }, + }, + }); + + t.is(parsed.done, false); + t.is(parsed.error, null); + t.is(parsed.ok, true); + t.is(parsed.token, null); + t.is(parsed.tokenKind, null); + t.is(parsed.toolCalls, null); + t.deepEqual(parsed.rawToolCallTokens, { + text: "raw", + ffi_error_message: "common_chat_parse failed: no parser", + }); +}); diff --git a/paddler_client_python/paddler_client/inference_message.py b/paddler_client_python/paddler_client/inference_message.py index f3bc248a..f1552e2e 100644 --- a/paddler_client_python/paddler_client/inference_message.py +++ b/paddler_client_python/paddler_client/inference_message.py @@ -8,6 +8,7 @@ from paddler_client.embedding import Embedding from paddler_client.parsed_tool_call import ParsedToolCall +from paddler_client.raw_tool_call_tokens import RawToolCallTokens class InferenceMessageKind(StrEnum): @@ -34,6 +35,7 @@ class InferenceMessageKind(StrEnum): TOOL_CALL_VALIDATOR_BUILD_FAILED = "tool_call_validator_build_failed" TOO_MANY_BUFFERED_REQUESTS = "too_many_buffered_requests" UNDETERMINABLE_TOKEN = "undeterminable_token" + UNRECOGNIZED_TOOL_CALL_FORMAT = "unrecognized_tool_call_format" _TOKEN_KINDS: frozenset[InferenceMessageKind] = frozenset( @@ -103,6 +105,7 @@ class InferenceMessage: error_code: int | None = None summary: GenerationSummary | None = None parsed_tool_calls: list[ParsedToolCall] | None = None + raw_tool_call_tokens: RawToolCallTokens | None = None generated_by: str | None = None @property @@ -276,6 +279,19 @@ def _parse_generated_token_result( generated_by=generated_by, ) + if "UnrecognizedToolCallFormat" in data: + raw_payload = data["UnrecognizedToolCallFormat"] + if not isinstance(raw_payload, dict): + msg = f"UnrecognizedToolCallFormat payload is not a dict: {raw_payload!r}" + raise ValueError(msg) + typed_raw = cast("dict[str, Any]", raw_payload) + return InferenceMessage( + request_id=request_id, + kind=InferenceMessageKind.UNRECOGNIZED_TOOL_CALL_FORMAT, + raw_tool_call_tokens=RawToolCallTokens.from_dict(typed_raw), + generated_by=generated_by, + ) + for key, kind in _GENERATED_TOKEN_KINDS.items(): if key in data: return InferenceMessage( diff --git a/paddler_client_python/paddler_client/raw_tool_call_tokens.py b/paddler_client_python/paddler_client/raw_tool_call_tokens.py new file mode 100644 index 00000000..37f4404c --- /dev/null +++ b/paddler_client_python/paddler_client/raw_tool_call_tokens.py @@ -0,0 +1,17 @@ +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any + + +@dataclass(frozen=True) +class RawToolCallTokens: + text: str + ffi_error_message: str + + @classmethod + def from_dict(cls, data: dict[str, Any]) -> RawToolCallTokens: + return cls( + text=str(data["text"]), + ffi_error_message=str(data["ffi_error_message"]), + ) diff --git a/paddler_client_python/tests/test_inference_message.py b/paddler_client_python/tests/test_inference_message.py index bb96b02b..96760b92 100644 --- a/paddler_client_python/tests/test_inference_message.py +++ b/paddler_client_python/tests/test_inference_message.py @@ -141,6 +141,51 @@ def test_parse_tool_call_validation_failed_with_non_list_payload_raises() -> Non parse_inference_client_message(data) +def test_parse_unrecognized_tool_call_format_response_carries_text_and_ffi_error() -> ( + None +): + data = { + "Response": { + "request_id": "req-1", + "response": { + "GeneratedToken": { + "UnrecognizedToolCallFormat": { + "text": "blah", + "ffi_error_message": "common_chat_parse failed: no parser", + }, + }, + }, + }, + } + message = parse_inference_client_message(data) + + assert message.kind == InferenceMessageKind.UNRECOGNIZED_TOOL_CALL_FORMAT + assert message.raw_tool_call_tokens is not None + assert message.raw_tool_call_tokens.text == "blah" + assert ( + message.raw_tool_call_tokens.ffi_error_message + == "common_chat_parse failed: no parser" + ) + assert not message.is_token + + +def test_parse_unrecognized_tool_call_format_with_non_dict_payload_raises() -> None: + data = { + "Response": { + "request_id": "req-1", + "response": { + "GeneratedToken": {"UnrecognizedToolCallFormat": "raw text only"}, + }, + }, + } + + with pytest.raises( + ValueError, + match="UnrecognizedToolCallFormat payload is not a dict", + ): + parse_inference_client_message(data) + + def test_parse_undeterminable_token_response() -> None: data = { "Response": { diff --git a/paddler_tests/tests/agent_controller_pool_re_selects_after_contended_claim.rs b/paddler_tests/tests/agent_controller_pool_re_selects_after_contended_claim.rs new file mode 100644 index 00000000..380a2805 --- /dev/null +++ b/paddler_tests/tests/agent_controller_pool_re_selects_after_contended_claim.rs @@ -0,0 +1,68 @@ +use std::sync::Arc; + +use anyhow::Result; +use anyhow::anyhow; +use paddler::balancer::agent_controller_pool::AgentControllerPool; +use paddler_tests::make_agent_controller_without_remote_agent::make_agent_controller_without_remote_agent; + +#[tokio::test(flavor = "multi_thread", worker_threads = 1)] +async fn agent_controller_pool_re_selects_after_contended_claim() -> Result<()> { + let pool = Arc::new(AgentControllerPool::default()); + + let controller_a = Arc::new(make_agent_controller_without_remote_agent("agent-a")); + controller_a.slots_total.set(4); + pool.register_agent_controller("agent-a".to_owned(), controller_a)?; + + let controller_b = Arc::new(make_agent_controller_without_remote_agent("agent-b")); + controller_b.slots_total.set(4); + pool.register_agent_controller("agent-b".to_owned(), controller_b)?; + + let candidate_first = pool + .select_least_busy_with_capacity() + .ok_or_else(|| anyhow!("expected a candidate when both agents have free capacity"))?; + let first_pick_id = candidate_first.agent_controller.id.clone(); + + assert_eq!( + candidate_first.snapshot, 0, + "snapshot must capture the value observed at selection time" + ); + + assert!( + candidate_first + .agent_controller + .slots_processing + .compare_and_swap(0, 1), + "simulated contender must succeed at incrementing the targeted agent before our claim" + ); + + let claim_outcome = pool.try_claim(candidate_first); + + assert!( + claim_outcome.is_err(), + "stale snapshot must produce a Contended (Err) outcome, not a successful claim" + ); + + let candidate_second = pool + .select_least_busy_with_capacity() + .ok_or_else(|| anyhow!("expected a candidate after re-selection"))?; + + assert_ne!( + candidate_second.agent_controller.id, first_pick_id, + "after a contended claim, re-selection must pick the truly-least-busy agent (the other one)" + ); + assert_eq!( + candidate_second.snapshot, 0, + "the un-contended agent's snapshot must still be 0" + ); + + let dispatched = pool + .try_claim(candidate_second) + .map_err(|_| anyhow!("fresh selection must claim the un-contended agent"))?; + + assert_ne!( + dispatched.agent_controller.id, first_pick_id, + "the dispatched agent must be the one selected after re-selection" + ); + + Ok(()) +} diff --git a/paddler_tests/tests/agent_pipeline_recognizes_duck_typed_tool_call_format_when_template_is_not_registered.rs b/paddler_tests/tests/agent_pipeline_recognizes_duck_typed_tool_call_format_when_template_is_not_registered.rs new file mode 100644 index 00000000..714d81c3 --- /dev/null +++ b/paddler_tests/tests/agent_pipeline_recognizes_duck_typed_tool_call_format_when_template_is_not_registered.rs @@ -0,0 +1,112 @@ +#![cfg(feature = "tests_that_use_llms")] + +use std::sync::Arc; + +use anyhow::Result; +use anyhow::bail; +use llama_cpp_bindings::ToolCallArguments; +use llama_cpp_bindings::llama_backend::LlamaBackend; +use llama_cpp_bindings::model::LlamaModel; +use llama_cpp_bindings::model::params::LlamaModelParams; +use paddler::tool_call_event::ToolCallEvent; +use paddler::tool_call_pipeline::ToolCallPipeline; +use paddler::tool_call_validator::ToolCallValidator; +use paddler_tests::model_card::ModelCard; +use paddler_tests::model_card::deepseek_r1_distill_llama_8b::deepseek_r1_distill_llama_8b; +use paddler_types::generated_token_result::GeneratedTokenResult; +use paddler_types::request_params::continue_from_conversation_history_params::tool::Tool; +use paddler_types::request_params::continue_from_conversation_history_params::tool::tool_params::function_call::FunctionCall; +use paddler_types::request_params::continue_from_conversation_history_params::tool::tool_params::function_call::function::Function; +use paddler_types::request_params::continue_from_conversation_history_params::tool::tool_params::function_call::parameters::Parameters; +use paddler_types::request_params::continue_from_conversation_history_params::tool::tool_params::function_call::parameters_schema::validated_parameters_schema::ValidatedParametersSchema; +use serde_json::Map; + +const QWEN_XML_PAYLOAD: &str = "\n\ +\n\ +\n\ +Paris\n\ +\n\ +\n\ +"; + +#[serial_test::file_serial(model_load, path => "../target/model_load.lock")] +#[test] +fn agent_pipeline_recognizes_duck_typed_tool_call_format_when_template_is_not_registered() +-> Result<()> { + let backend = LlamaBackend::init()?; + + let ModelCard { + gpu_layer_count, + reference, + } = deepseek_r1_distill_llama_8b(); + + let path = hf_hub::api::sync::ApiBuilder::from_env() + .build()? + .model(reference.repo_id.clone()) + .get(&reference.filename)?; + + let model_params = LlamaModelParams::default().with_n_gpu_layers(gpu_layer_count); + let model = Arc::new(LlamaModel::load_from_file(&backend, &path, &model_params)?); + + let mut location_properties = Map::new(); + location_properties.insert( + "location".to_owned(), + serde_json::json!({"type": "string", "description": "The city name"}), + ); + let tools = vec![Tool::Function(FunctionCall { + function: Function { + name: "get_weather".to_owned(), + description: "Get the current weather for a location".to_owned(), + parameters: Parameters::Schema(ValidatedParametersSchema { + schema_type: "object".to_owned(), + properties: Some(location_properties), + required: Some(vec!["location".to_owned()]), + additional_properties: Some(serde_json::Value::Bool(false)), + }), + }, + })]; + + let validator = ToolCallValidator::from_tools(&tools)?; + let tools_json: Vec = tools + .iter() + .map(serde_json::to_value) + .collect::>()?; + let mut pipeline = ToolCallPipeline::new(model, &tools_json, validator)?; + + pipeline.feed(QWEN_XML_PAYLOAD); + let event = pipeline.finalize(); + + let ToolCallEvent::Resolved(parsed_calls) = event else { + bail!( + "duck-type pass must recover Qwen XML on a model with no registered template; \ + expected ToolCallEvent::Resolved, got {event:?}" + ); + }; + assert_eq!( + parsed_calls.len(), + 1, + "expected exactly one parsed tool call; got {parsed_calls:?}" + ); + assert_eq!(parsed_calls[0].name, "get_weather"); + + let mapped = ToolCallEvent::Resolved(parsed_calls) + .into_generated_token_result() + .ok_or_else(|| anyhow::anyhow!("Resolved must produce a GeneratedTokenResult variant"))?; + let GeneratedTokenResult::ToolCallParsed(wire_calls) = mapped else { + bail!("expected GeneratedTokenResult::ToolCallParsed after mapping"); + }; + assert_eq!(wire_calls.len(), 1); + assert_eq!(wire_calls[0].name, "get_weather"); + let location = match &wire_calls[0].arguments { + ToolCallArguments::ValidJson(value) => value + .get("location") + .and_then(|v| v.as_str()) + .map(str::to_owned), + ToolCallArguments::InvalidJson(raw) => { + bail!("expected ValidJson, got InvalidJson: {raw}"); + } + }; + assert_eq!(location.as_deref(), Some("Paris")); + + Ok(()) +} diff --git a/paddler_tests/tests/balancer_in_process_shutdown_with_open_sse_subscriber_completes_within_one_second.rs b/paddler_tests/tests/balancer_in_process_shutdown_with_open_sse_subscriber_completes_within_one_second.rs new file mode 100644 index 00000000..9c9d54bb --- /dev/null +++ b/paddler_tests/tests/balancer_in_process_shutdown_with_open_sse_subscriber_completes_within_one_second.rs @@ -0,0 +1,43 @@ +use std::time::Duration; + +use anyhow::Result; +use anyhow::anyhow; +use futures_util::StreamExt as _; +use paddler_tests::in_process_cluster_params::InProcessClusterParams; +use paddler_tests::start_in_process_cluster::start_in_process_cluster; +use tokio::time::timeout; + +#[tokio::test(flavor = "multi_thread")] +async fn balancer_in_process_shutdown_with_open_sse_subscriber_completes_within_one_second() +-> Result<()> { + let cluster = start_in_process_cluster(InProcessClusterParams { + spawn_agent: false, + wait_for_slots_ready: false, + ..InProcessClusterParams::default() + }) + .await?; + + let mut sse_stream = cluster + .paddler_client + .management() + .get_buffered_requests_stream() + .await + .map_err(anyhow::Error::new)?; + + let _first_snapshot = timeout(Duration::from_secs(1), sse_stream.next()) + .await + .map_err(|elapsed| anyhow!("first SSE snapshot must arrive within 1s: {elapsed}"))? + .ok_or_else(|| anyhow!("SSE stream closed before first snapshot"))? + .map_err(anyhow::Error::new)?; + + timeout(Duration::from_secs(1), cluster.shutdown()) + .await + .map_err(|elapsed| { + anyhow!( + "balancer in-process shutdown with an open SSE subscriber must complete within \ + 1s after cancel; got: {elapsed}" + ) + })??; + + Ok(()) +} diff --git a/paddler_types/src/generated_token_result.rs b/paddler_types/src/generated_token_result.rs index 77baa845..1f12d764 100644 --- a/paddler_types/src/generated_token_result.rs +++ b/paddler_types/src/generated_token_result.rs @@ -4,6 +4,7 @@ use serde::Serialize; use llama_cpp_bindings_types::ParsedToolCall; use crate::generation_summary::GenerationSummary; +use crate::raw_tool_call_tokens::RawToolCallTokens; use crate::streamable_result::StreamableResult; #[derive(Debug, Deserialize, Serialize)] @@ -26,6 +27,7 @@ pub enum GeneratedTokenResult { ToolCallValidationFailed(Vec), ToolSchemaInvalid(String), UndeterminableToken(String), + UnrecognizedToolCallFormat(RawToolCallTokens), } impl GeneratedTokenResult { @@ -178,4 +180,18 @@ mod tests { assert!(!event.is_tool_call_parsed()); assert!(event.is_tool_call_failure()); } + + #[test] + fn unrecognized_tool_call_format_is_not_done_and_not_classified_as_token() { + let event = GeneratedTokenResult::UnrecognizedToolCallFormat(RawToolCallTokens { + text: "raw output".to_owned(), + ffi_error_message: "parser bailed".to_owned(), + }); + + assert!(!event.is_done()); + assert!(!event.is_token()); + assert!(event.token_text().is_none()); + assert!(!event.is_tool_call_parsed()); + assert!(!event.is_tool_call_failure()); + } } diff --git a/paddler_types/src/lib.rs b/paddler_types/src/lib.rs index ec3ad59a..823c0b84 100644 --- a/paddler_types/src/lib.rs +++ b/paddler_types/src/lib.rs @@ -34,6 +34,7 @@ pub mod media_marker; pub mod model_metadata; pub mod normalization; pub mod pooling_type; +pub mod raw_tool_call_tokens; pub mod request_params; pub mod rpc_message; pub mod slot_aggregated_status_snapshot; diff --git a/paddler_types/src/raw_tool_call_tokens.rs b/paddler_types/src/raw_tool_call_tokens.rs new file mode 100644 index 00000000..88f39ebf --- /dev/null +++ b/paddler_types/src/raw_tool_call_tokens.rs @@ -0,0 +1,25 @@ +use serde::Deserialize; +use serde::Serialize; + +#[derive(Debug, Deserialize, Serialize)] +#[serde(deny_unknown_fields)] +pub struct RawToolCallTokens { + pub text: String, + pub ffi_error_message: String, +} + +#[cfg(test)] +mod tests { + use super::RawToolCallTokens; + + #[test] + fn carries_text_and_ffi_error_message() { + let tokens = RawToolCallTokens { + text: "raw payload".to_owned(), + ffi_error_message: "parser bailed".to_owned(), + }; + + assert_eq!(tokens.text, "raw payload"); + assert_eq!(tokens.ffi_error_message, "parser bailed"); + } +} From 019b0ddd8414f556e058a9e6f568aee55ba9e423 Mon Sep 17 00:00:00 2001 From: Mateusz Charytoniuk Date: Mon, 11 May 2026 17:40:09 +0200 Subject: [PATCH 30/51] Install shutdown signal handlers synchronously before bootstrap so SIGTERM during startup yields a clean exit --- paddler_bootstrap/src/shutdown_signal/mod.rs | 8 +++- paddler_bootstrap/src/shutdown_signal/unix.rs | 35 +++++++++++---- .../src/shutdown_signal/windows.rs | 44 ++++++++++++++----- paddler_cli/src/main.rs | 5 ++- paddler_client_cli/src/main.rs | 5 ++- paddler_gui/src/app.rs | 13 +++++- 6 files changed, 82 insertions(+), 28 deletions(-) diff --git a/paddler_bootstrap/src/shutdown_signal/mod.rs b/paddler_bootstrap/src/shutdown_signal/mod.rs index 2aa84865..9453fb58 100644 --- a/paddler_bootstrap/src/shutdown_signal/mod.rs +++ b/paddler_bootstrap/src/shutdown_signal/mod.rs @@ -4,6 +4,10 @@ mod unix; mod windows; #[cfg(unix)] -pub use unix::wait_for_shutdown_signal; +pub use unix::ShutdownSignals; +#[cfg(unix)] +pub use unix::register_shutdown_signals; +#[cfg(windows)] +pub use windows::ShutdownSignals; #[cfg(windows)] -pub use windows::wait_for_shutdown_signal; +pub use windows::register_shutdown_signals; diff --git a/paddler_bootstrap/src/shutdown_signal/unix.rs b/paddler_bootstrap/src/shutdown_signal/unix.rs index 969f9c37..411d5531 100644 --- a/paddler_bootstrap/src/shutdown_signal/unix.rs +++ b/paddler_bootstrap/src/shutdown_signal/unix.rs @@ -1,19 +1,36 @@ use anyhow::Context as _; use anyhow::Result; use log::info; +use tokio::signal::unix::Signal; use tokio::signal::unix::SignalKind; use tokio::signal::unix::signal; -pub async fn wait_for_shutdown_signal() -> Result<()> { - let mut sigterm = signal(SignalKind::terminate()).context("failed to listen for SIGTERM")?; - let mut sigint = signal(SignalKind::interrupt()).context("failed to listen for SIGINT")?; - let mut sighup = signal(SignalKind::hangup()).context("failed to listen for SIGHUP")?; +pub struct ShutdownSignals { + sigterm: Signal, + sigint: Signal, + sighup: Signal, +} + +impl ShutdownSignals { + pub async fn wait(mut self) -> Result<()> { + tokio::select! { + _ = self.sigterm.recv() => info!("Received SIGTERM"), + _ = self.sigint.recv() => info!("Received SIGINT"), + _ = self.sighup.recv() => info!("Received SIGHUP"), + } - tokio::select! { - _ = sigterm.recv() => info!("Received SIGTERM"), - _ = sigint.recv() => info!("Received SIGINT"), - _ = sighup.recv() => info!("Received SIGHUP"), + Ok(()) } +} + +pub fn register_shutdown_signals() -> Result { + let sigterm = signal(SignalKind::terminate()).context("failed to listen for SIGTERM")?; + let sigint = signal(SignalKind::interrupt()).context("failed to listen for SIGINT")?; + let sighup = signal(SignalKind::hangup()).context("failed to listen for SIGHUP")?; - Ok(()) + Ok(ShutdownSignals { + sigterm, + sigint, + sighup, + }) } diff --git a/paddler_bootstrap/src/shutdown_signal/windows.rs b/paddler_bootstrap/src/shutdown_signal/windows.rs index 862186fa..b019c78f 100644 --- a/paddler_bootstrap/src/shutdown_signal/windows.rs +++ b/paddler_bootstrap/src/shutdown_signal/windows.rs @@ -1,23 +1,45 @@ use anyhow::Context as _; use anyhow::Result; use log::info; +use tokio::signal::windows::CtrlBreak; +use tokio::signal::windows::CtrlC; +use tokio::signal::windows::CtrlClose; +use tokio::signal::windows::CtrlShutdown; use tokio::signal::windows::ctrl_break; use tokio::signal::windows::ctrl_c; use tokio::signal::windows::ctrl_close; use tokio::signal::windows::ctrl_shutdown; -pub async fn wait_for_shutdown_signal() -> Result<()> { - let mut ctrl_c = ctrl_c().context("failed to listen for Ctrl+C")?; - let mut ctrl_break = ctrl_break().context("failed to listen for Ctrl+Break")?; - let mut ctrl_close = ctrl_close().context("failed to listen for console close")?; - let mut ctrl_shutdown = ctrl_shutdown().context("failed to listen for system shutdown")?; +pub struct ShutdownSignals { + ctrl_c: CtrlC, + ctrl_break: CtrlBreak, + ctrl_close: CtrlClose, + ctrl_shutdown: CtrlShutdown, +} + +impl ShutdownSignals { + pub async fn wait(mut self) -> Result<()> { + tokio::select! { + _ = self.ctrl_c.recv() => info!("Received Ctrl+C"), + _ = self.ctrl_break.recv() => info!("Received Ctrl+Break"), + _ = self.ctrl_close.recv() => info!("Received console close"), + _ = self.ctrl_shutdown.recv() => info!("Received system shutdown"), + } - tokio::select! { - _ = ctrl_c.recv() => info!("Received Ctrl+C"), - _ = ctrl_break.recv() => info!("Received Ctrl+Break"), - _ = ctrl_close.recv() => info!("Received console close"), - _ = ctrl_shutdown.recv() => info!("Received system shutdown"), + Ok(()) } +} + +pub fn register_shutdown_signals() -> Result { + let ctrl_c = ctrl_c().context("failed to listen for Ctrl+C")?; + let ctrl_break = ctrl_break().context("failed to listen for Ctrl+Break")?; + let ctrl_close = ctrl_close().context("failed to listen for console close")?; + let ctrl_shutdown = ctrl_shutdown().context("failed to listen for system shutdown")?; - Ok(()) + Ok(ShutdownSignals { + ctrl_c, + ctrl_break, + ctrl_close, + ctrl_shutdown, + }) } diff --git a/paddler_cli/src/main.rs b/paddler_cli/src/main.rs index 2bf0ad5c..0e06b294 100644 --- a/paddler_cli/src/main.rs +++ b/paddler_cli/src/main.rs @@ -8,7 +8,7 @@ use cmd::balancer::Balancer; use cmd::handler::Handler as _; #[cfg(feature = "web_admin_panel")] use esbuild_metafile::instance::initialize_instance; -use paddler_bootstrap::shutdown_signal::wait_for_shutdown_signal; +use paddler_bootstrap::shutdown_signal::register_shutdown_signals; use tokio_util::sync::CancellationToken; #[cfg(feature = "web_admin_panel")] @@ -42,11 +42,12 @@ enum Commands { async fn main() -> Result<()> { env_logger::Builder::from_env(env_logger::Env::default().default_filter_or("info")).init(); + let shutdown_signals = register_shutdown_signals()?; let shutdown = CancellationToken::new(); let signal_shutdown = shutdown.clone(); tokio::spawn(async move { - if let Err(error) = wait_for_shutdown_signal().await { + if let Err(error) = shutdown_signals.wait().await { log::error!("shutdown signal listener failed: {error}"); return; } diff --git a/paddler_client_cli/src/main.rs b/paddler_client_cli/src/main.rs index ba7aec89..fb47974c 100644 --- a/paddler_client_cli/src/main.rs +++ b/paddler_client_cli/src/main.rs @@ -17,7 +17,7 @@ use clap::Parser; use clap::Subcommand; use cmd::handler::Handler as _; use cmd::prompt::Prompt; -use paddler_bootstrap::shutdown_signal::wait_for_shutdown_signal; +use paddler_bootstrap::shutdown_signal::register_shutdown_signals; use tokio_util::sync::CancellationToken; #[derive(Parser)] @@ -36,11 +36,12 @@ enum Commands { async fn main() -> Result<()> { env_logger::Builder::from_env(env_logger::Env::default().default_filter_or("info")).init(); + let shutdown_signals = register_shutdown_signals()?; let shutdown = CancellationToken::new(); let signal_shutdown = shutdown.clone(); tokio::spawn(async move { - if let Err(error) = wait_for_shutdown_signal().await { + if let Err(error) = shutdown_signals.wait().await { log::error!("shutdown signal listener failed: {error}"); return; } diff --git a/paddler_gui/src/app.rs b/paddler_gui/src/app.rs index e16cd19f..7bfa8024 100644 --- a/paddler_gui/src/app.rs +++ b/paddler_gui/src/app.rs @@ -34,7 +34,7 @@ use paddler_bootstrap::agent_runner::AgentRunner; use paddler_bootstrap::agent_runner::AgentRunnerParams; use paddler_bootstrap::balancer_runner::BalancerRunner; use paddler_bootstrap::balancer_runner::BalancerRunnerParams; -use paddler_bootstrap::shutdown_signal::wait_for_shutdown_signal; +use paddler_bootstrap::shutdown_signal::register_shutdown_signals; use paddler_types::balancer_desired_state::BalancerDesiredState; use tokio::sync::broadcast; use tokio_util::sync::CancellationToken; @@ -64,7 +64,16 @@ static BETA_IMAGE: LazyLock = LazyLock::new(|| { fn shutdown_signal_stream() -> impl iced::futures::Stream { iced::stream::channel(1, async move |mut output| { - if let Err(error) = wait_for_shutdown_signal().await { + let shutdown_signals = match register_shutdown_signals() { + Ok(shutdown_signals) => shutdown_signals, + Err(error) => { + log::error!("failed to register shutdown signal handlers: {error}"); + + return; + } + }; + + if let Err(error) = shutdown_signals.wait().await { log::error!("shutdown signal listener failed: {error}"); return; From 7f0ccc3bd66584e290367f527918cc79b5eb8a63 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ma=C5=82gorzata=20Zagajewska?= Date: Mon, 11 May 2026 23:18:47 +0200 Subject: [PATCH 31/51] Surface oversized image inputs as a typed wire variant instead of crashing; rename batch_n_tokens to n_batch --- fixtures/sarnow.jpeg | Bin 0 -> 538611 bytes paddler/src/agent/continuous_batch_arbiter.rs | 7 ++ .../continuous_batch_embedding_processor.rs | 6 +- .../assemble_batch_phase.rs | 10 +- .../continuous_batch_scheduler/batch_pass.rs | 4 +- .../agent/continuous_batch_scheduler/mod.rs | 63 ++++++++--- paddler/src/agent/plan_embedding_batches.rs | 4 +- .../http_route/post_chat_completions.rs | 53 ++++++++++ .../api/post_generate_embedding_batch.rs | 2 +- paddler_client_cli/src/stop_reason.rs | 10 ++ paddler_client_cli/src/streaming_response.rs | 3 + .../src/schemas/InferenceParameters.ts | 2 +- .../InferenceServiceGenerateTokensResponse.ts | 16 +++ ...renceServiceGenerateTokensResponse.test.ts | 21 ++++ .../paddler_client/inference_message.py | 16 +++ .../paddler_client/inference_parameters.py | 2 +- .../paddler_client/oversized_image_details.py | 17 +++ .../tests/test_client_management.py | 2 +- .../tests/test_inference_message.py | 66 ++++++++---- paddler_client_python/tests/test_tool.py | 12 ++- paddler_tests/src/lib.rs | 1 + ...ocess_cluster_with_smolvlm2_and_n_batch.rs | 46 ++++++++ ...pletes_generation_with_adequate_n_batch.rs | 96 +++++++++++++++++ ...agent_does_not_crash_on_oversized_image.rs | 99 ++++++++++++++++++ ...istribution_independent_of_context_size.rs | 2 +- ..._evicts_long_sequence_under_kv_pressure.rs | 2 +- paddler_types/src/generated_token_result.rs | 15 +++ paddler_types/src/inference_parameters.rs | 4 +- paddler_types/src/lib.rs | 1 + paddler_types/src/oversized_image_details.rs | 9 ++ resources/ts/components/ChangeModelForm.tsx | 2 +- 31 files changed, 533 insertions(+), 60 deletions(-) create mode 100644 fixtures/sarnow.jpeg create mode 100644 paddler_client_python/paddler_client/oversized_image_details.py create mode 100644 paddler_tests/src/start_in_process_cluster_with_smolvlm2_and_n_batch.rs create mode 100644 paddler_tests/tests/agent_completes_generation_with_adequate_n_batch.rs create mode 100644 paddler_tests/tests/agent_does_not_crash_on_oversized_image.rs create mode 100644 paddler_types/src/oversized_image_details.rs diff --git a/fixtures/sarnow.jpeg b/fixtures/sarnow.jpeg new file mode 100644 index 0000000000000000000000000000000000000000..a8b67b1387183b6de38b29915b20d6e8b4f9cce6 GIT binary patch literal 538611 zcmcG$1z1$u*EoEJp*sZWkWvZhh9RW8OHdeUU>F({Bm@OSX+c7yyQE7*r9`9~q?M8Z zl+vNT1K#VszxRFr@qFL&ZJx8ws=fExYv;^b!`bB7BCw!*7vTT^>gv}4d;kC-03re# z%mIKAe10bg5T1Ys6oH7q-y}f6P)S`K#O&ZtCn6ytAr2Rlw6_ryw-vCH5RkC7v$GQu6c!eea1as_ z7m^SY6BD$vwGk8)mXH({5Q0kx*nuJ{Tg>hDg2MzQt$gBUn^Y5zf#JW)#jASmyD5G`Az*B_3K zqqpxLMA;R2^A7^r{hz88;VzDUndG8-0AQf1s03;QJy*%Vj+NEU&Bs>WZQ&ffNUENw z|3*_p{&zV=Pdg)Hl(!1I0rGq<{7H&PJJ$b+Zs3LVzM$*fMaskeh!gtS!@b=+Z`yd< zocG{DNj*nBufJ!}Ieab*zR%SLwCo*zSMy(*H3H}L|J1A@9Qm8B=LwfLKd<2;k;6UR z+)bVA!NCP*Gy>)LxA{oV83v$Eif-QCZmvi-l;dATi2vrCtMD&6$=`GXgrn2H8AN|G zK;8bxpRX+_`nL`M3;2eBe}BC%)*aUckMyrb!51t9_&wi9E@;?bZgC+=_XlUbsCD-O zC%DML402J5`}gW5{5`wR$rrNw7gB1#4F?C1g9LNj3ku{fN_J}6ABxaLWfoxmw~Xio z<$UJ!3Iopm#{^wFXaA3^v;Qx=3tOCT+JDv|mOQvF!RIf#F`q-gx!r!- zk^UTl-UhSZ+MlBUHjp_HUa&F}R zp8iEUukpO*|4jc9_`~>T3jt6*$n%{M3w&^G-F#4XUjJnU!@UJT-bIJbr~RL~1rqvk9Ke_cp#Y)eDE$1^_%}{3(wKh&By&e zNVsmE2uH-9{ZrZSyms9S8dwL3jgPmRsy)iy)5hE04kQ3Z_uT(JE(m`MpHt2`>aLEg z;OqbI{zH8{k$)XJe+|mH@&A*e?dACQ!Sq`O*9PfrWaIcBQlxNuu$=u}Z*?y{$^Rit;N<4%uYg22{%t9;-v6jE|2`fKN<7KukeINJK$RModgb zO-n^ZO+`gZL41+^di_TJYYrhHA|fFrAtxmzzeGw(dg(kPz4W^X#s5tOXKw)td{Fr; zDC7!&MFD|QK+a~twMux_0Rc}v2oB^TfeN1|C;-92#=*tICmLI5o2-%N4< zf(?OUVPoOo;o{>!iG@KX1r(c&QV>T$--han#{(hUOYzxNitHTJ2JdWzMLa`?l<+tc z40jg4!@Wdla;n3)ZYCPpDZkJ47P~xbyk!5Q=3)DY&+@Jc_p`i5wMiYLU-t6rI>)~5 zn>hG}Cl}OrjjtSttC~9cMLaKT=$=?TqyVt6Ky9%vbi%{M5xr1>O^^~B)WG8k6^_t@ z_)F&+yfYZu5$3R6^bAcLrp@;1ZexAXiMZQj_HG!H2+0=n z&H26HC6^PHqE~A`=&JN^00S=;w04#cy+`xNPn(nkNRRZI)EOtqs=1wQ+Hfj77wU?3 z$BapQYE9MJE6vZ|$9i^GCMf+ooI@k zLf^6%Z4qbGb9w6g>C3Gjz6N(BD+^UlaMAm&r>ODZcNG!T{aqC$#l-_Qkfj0%6l$$J z;|$Pj%2a%Q2CUW^>dZyN<*W!W-O+hqZ|76yyivn)uV8$f$uKJ#Ew?=++fpytH?wyT zEV*g?U1}`#p3(5aH`WMr$nCntm(Obh0$}T~^=#*y&F18nAA%hnL;Jfnl7<2@lTh1M zvDNk8G$cMiiNE(1zdjsEf03$t_Sx1x(elNy&7 zS5o(Is|JYY>62qL_zt2b)sG+Uo*2iD3U5|Kd=Y!!W%OeL9S376L?52onnZb?zWO?5 zrC89|G%!}NgG9~PFj&vX&ZO+eMvvYoWXi5QFykCZ^^SFfDYqO1ifFw*F09@*V&H|u zJg1b0wYH!(KF<#r6_rU7)^9!0Y!%muVGSsK)w0V|kv!FO1}Ob%9@ZJK8N}36XQym) zSTa1xEavx+%e6YGbakH4Z-h5(wWCC7X6nWrJ|9pLdsws0`_xMnAmKcYZT`!}T=jCB ziqmEA7iQQ<28dsa-#YU_)8>Ke84#^lSTYmIS4r{p*F-a4ue)oyJhzvJeN3SA8Ss9p zZ5O7zo~36gIcqz;)pYQAY1w6xk@XE-7OGbqLtGR5W_~qT6{D+9bdc!U?PJ!cU8K|G zN9XQ>yP|2?)V3;oN@Bk~GU775?pytKCB(kp_ea;%l=|KzzK!o{29W{+TW4qf>rvZ1 z@h8oX_KZ{cv1`Ws^4YQy(9&*`%WY=>YYuOtZ1t*S#h^NeI!pT22}1@({HmoATNe3@ z%Bn=qL1L~YY+Wq7Y5z239tI=U|ebG9rbJ}w$e0S~^ zb%jLoyP{@}$`55P)#SI|-4~hA@94q64#!HvT!N;1GtGhNK(Sa4=H&*L&*-`tcvsgM z&}nYl-8J4Fx`n&irsj0K+H@3H=C;Zomaq58wwENRWm?>d?GxXF2DtUfSOD@2P&)&D z>aGkDk|`KgGY$*!(fV0@V=@^h3HgSeWZX7Htp+8If}>?YI|k#vOmj%T5si@6Y_K>u zOB8-+T;E!_uCH?ho>zNMRr_7_^D4w_2K*%YQErTQ7t7;`HdN>H=c8Rm z8>me`(ayeZZx)w&qwdQCt9D|2(U<_14R-Ijh?iYNen@Frp0@jvV-3GrqYwRAzU!O@ z9AAkrzt>gYAXzuLf-V^sv(VYj`QGwN$BVviK6f8BltcYxaTPxG*rqNVm_9IONXPT4 z*R-rE^EShj*=FlQ3oY-T0n@Q+a+;c{(rNQrP5lSbV-}KZWA>9>mvO6FaCBzs(ht+B zPNe-d3rAgJPQ8vftuqTTQsXAf%0ZEh(t9z!>2IZNyJdpYVIS0SCfWmHr+KMyM`!mj zDC|rm#-Xcf2A1Jhymtm@yREGUuZ;y<>-$hvJ!A--qh-YRnswZ@iP&81KVb+C8rX@v z+w{y5gS$;L>1$kBHQqcscc`w%oBF&aPZg#E%lSOut-^A*n;*DXbm0jB6R7SUj2S=cA_@>xqYh6jVpU9h_ihfI4^64m%#A?}zf@Y@640k4B4S+5qK4Skhb zvFYEq(U=*i1<5YdK6+9%bNoclqrz`;PN&;HpwA~xv$sOpwaqKAqAGgIDyZKUR8c@T0OM3 zeFos&KA=pm+Bklp9daI-x};1 zC%DT`KMqcD7F4&}2~XQ61yr@WZ8?Pe%qwo>0`<7-ZU=OH)dSb(T19ku*YbjG%Y6lT~CWfXOQuUq%smfW-6570E)Sxtv?^2=ZBBni z#KTVogWo*5R8aD)u)S3@e@B7tUbxu2b$3wIYmHW^Ga!E+!?)?b%|ywANOR zTEY8a=47A~(T;KRz3BQ)6FYM2iIF?ac!0nC8IX&y?iTmm-l+pmkurgA@_b|E2}G*T zTaNoa4)IIG)Jm)^4T{U-^ueZ&x6xU$XpgF4J8>5VJOjt|oX{2kQI@eJJ2#0q; z%aQRjRXrPCyiUrw15d0-%MK;hrN@#{jQK%tA&ULeIfaUqqG&TJ0Sm3}#}y<{p=UO< zVfHgo2(L$%dKHY1SL5e+jJo-#^z17cLJAI&>1k~>HFpL5>NTp{r;u* z>I`iG@Z><#bb9)$2*By)8Q_1_y||A*3rnTKiNh)Qxx;t?MRR}@?(jjl?826m#F2a= zdScgDlQ(VN<-se#W*1{rrKI3I6#Z zT=VH%RCc;G)pb`OZB~hXVVIz3Y{NYH!@+jWxW9D?%Q)eBMH%5_*Q;cS4A#`W^u|-a zm9lOh#sV>V08y&+CS2gVO2S#`}9Dpu<-$$c|wF>S{e$a#G63I%z@M6`>qWooaU{c+t zk9=JPDQDlEp?>~=*)u?yAs;n@T5YrUZ})Sj$9=i#Y#im%SyC`{$3sX~<#l+iOT(`0 zOmN-FLlf_rs{xZ6srJ$KHxrcdU|uO74`^y;Yi64?HhCZDya@|S!v}}W=Dq!l>G?H9) z2)n@&c5nTCf3NTC*~o4;0q0wRmy?Zr-0zee}q($b}I|Wt*GOIs=T*rfrU<4#I0*OCQ1;0SlPN%&pl9`LHQQxf@qz zDxKU(Qb!CqVnY;MxA06UWe8--`LiEqhA8oQSFP~4yZE;JvIt}<6?i?a9^PyLkK;a) z)?-!|WEv)pz^m#`AQO-WZ`^Fw1eMg`N?B?+@9(6f$ctMxB4c$Ej}2uLw`@{VWeBuyugy56LL%A^ z)8_kOA8!JomyRrvLIrU@0`q8p9#|Ml^G$Zvb){$dyZhb6JoOx?WZNr)Iv+0t>XHDBP(p=f7>T_}EJcxGX~TXlm*F#984Ii7WT$(E!GJ-mSFlnB~Wqon&IyZaMqn$9=lQPKHjuM`A^ao=>i ze+z`UoQ77sf3N0Ej#rBwmb2tNMt_K@=(aR_RbLO&TaA-vQ7-5fpRIc{`*L*5dDk2f z#yUBxG8|apaL}+WDB)B-e>S?%CPg}oP0rRfRq3~K)Fvsw2Z+AQpNX7j%?OrmTjl0M z6lQuocISTZs{*~}4W43!K|i_(4*H{|AWSDm1{z`4x%G(SS=%)&boX1oDHRA6T8zi7 zQt^G@3QTDMIFHJ$lhlTq9W_TQUvtZE62Iq41- zZ)MHdHybs!gk})?an7q|Fr2hu2ydgZlWA^^sF8|RZAa&kaQ|W$59Hk9o58Ts1Be8* zQ?-5R(e0y`Il{gY4_fwwN#pAl)1on_2LlCRPF@uv65~9)UiP6b z<)V-yue*I+(q9-W<&@I=bKcKCLzv6~CjwG71(2Z&*`MNb3TzD17i$?$%tE|{`> z_#8kl&bGES_O-RyRtRM{$ofS;SgfnxSW@6SVSull#+SRiiC9BBbOnkc?MHmCiBi-R zn7M>53zG(9U^Ku>>nRDnaUI>TYsrw*9*Yvt*=e5E+sUdW&P zI@!*z(;p38fLaO7W&xthhxJEE@%Glw3%bP$qZzvghEJ8+ebIhD5?I+XQtK$Ys-h97 za`$M`w!;b58qVzeJ`b12S1j%{2Km*sy?#)eTGo_Z$(o?`n2PkEw+*&SxpzC>Mczdv zgee*-&tYEIrWp`8Gq?z`I8>X_G<$z*Jc1@%HqLqtqYcY69hp6@gJUIceeIwLaR5ec zyB|uA!iQ0#8svwZjV+?>lT7Hm zv~k6%Vi2ebwtf$vbaRX@hLDU6z4uly+u`9B`{cM7!`3`cal_aV_xcG;BUrcj3j53k zCNr(+c%UYzZmny;t*~t@Q^)&=r4R$F*~T#E057xooXxr)d*xC3kh3e=kNhg1JtZ5p zWD;s_w;t4dj2FyEXM^fSomzB`-|Si!WPp30q6UV_tuustgT+rTFH51oqf>#S`L3_2 zwsw*1IBp6aYJz3<`@y%WC|!f)T(F<`T3;fP2~JWu^do_i%)WEi^WQdTgymr@vjnd8sHld{mAQ3dZN^Z z!-GcTA^4rA=BaI!U+#vq-^ejKy;c2SYpQME4ZNgV=}Y?wqxJWQQOn@>A=9y%do72- z8yrT7R8k8>uF{dL5~p_$*3sN+b*kbsE^yQoX@G?z=FN)AO0)iVUw_rj?+qgihr1_@ zMl;MchaH-JTIke~pn24&o_^D~xm#6B8pPFgfud~d?x{!l8BlTE6&@%iP>VH^YuD68 zl;5Hh7=3)elln5VWZQ5Jh5E31+EBA+*;bYk80I4GKh%h6Q}yjRJYgB$1TQx&;0I3D zJe~5Va6ZnydL^Y0nc6de@69SwrQ(I&&WzvX%UEgwDdSt%L5b6S84)k_Y?<~Tl;xh(RHnm#bKs#m^^fw-|~9E+i~ zHv|lR63yMDS`jGp@?O#=xX6453b`Dvz-QLqmp*4GNlkGJ?+F7x>@Efm2ZzdY{DhrN z{q~!%UAPSl!5nh#C?NwB06Q>F!WN8&UJN|}wFJykh^5?Lf zD++W;Vx8;kZs%%u4tIcXq_5Aps}iaO!r{IMdp{5!1z`@Pk1GO%&wbvPT61$_6B{1pe&%|=6>(Oi^h5tevMU7Qc{9d z&E5}b@9oW}X9IV!@w8)Aa&vXJL4lDgzx#Y%3ZOi3_@o zTj>ArJ|ElPBD?w*V+Nu5^bhTy!vE0RazH;j7+(O5`-f(m3IGj{Ku_NEKQxXkaQ;37 zfck-d^@rwszBqY%yGz};;pgYakFbaHpBwZ~`acT%k^El+|7wr_yuClRW4-y?>B@R; zR5<8F_3>o&a<_rov-17dN&Nr1;J?)Rmma(Z_73)*_9(C^6L6M+0V&{cqwEmp5k#yA z)ch!umCf|Y|+hE>})X z><;V^>_zMy>{A>P97Y@-94Q=i95Wn896y|JoMfB=oI0FNoH3lQI0v}6xHP!jxZ=2K zxaPRdxB<9NaWil$ao^$&;V$FujtVDc^*oQcV zxRCfQ@f7hc2?+@oi9Cq~i8o0MNij(~$sEZ~QYun@Qgu>0(qPhb(t6Ti(k(IqGEOoD zGHWt_vShLvvH`M9asqNLawT#b@*whb@+R^L@_h;_3PB283RjA!6eScr6l;`tlw6d` zly;O4DRU{?DVM3BRP0nrRB);=sywPrs;`%DFL7T|zvO)B$))m3{g-yADXB%MO{niu zr&G65FVH|~xM=MGi@@{Fv~MLGe2W~!@SNy#UjUoV0p&U#DCL;uB;b_bbmDx@+0A*# z#m!~P6~WcOwZ=`$tWyvkT*kCrW9N3aPyS$xzf&8k%H3esdGKDQg0Yy*6 zI>kdJDWyQAw>P0TRc}V!>{q5#Hdanko>RG^;;2%pvZE@h8ld`C4M$B=EnaOxok<<8 zUaY>OA+8aq(V&k^U!P2 z$JN)$9k5dr7yGdXhzwGoKSa_j$gMqLtE^%8?qE zdYtBzHkPiKUipIY#p4%;8Fm@NFXdlWWHMzw$;4zKvL>@tv+HxXa*}g#bA5AH@=Wr& z^Cj|&3+M};6r2>I3KxnDiaLtLi;GJbOJYm0N_|S#%WjnomftLItl+E2uB54a@(Ot6 z^J=5&PStp|X7&3TiJFR9&e|7sRCQ7HkotS|I}J_^3ytQDLroe@@0+EY>st6*3SYCm zPH&}djeSG-=HZ*OHvhK$x9)E@-#NZpet-M@OuJe8Xor4Bf2U^Whc1<__HKplwhyu& zUiV1#G<}r#*w8E1TmMP)Q(d2EUtPagfBk^?K;xj~VDpg7(3@e{@Vk+lBVD8FqrGD~ zV?*P{G&8M2D zpU+&uDA0eRx8%XQ!0+g--?35uS2@-{3Hm4ftK@mC64oEa|1EG4y#+bK+JZvBaHoHw zx2(bFE$~_k;9`LhNPiL@J}wS60hAC60)Ww504@}4{|^&nLGiHgAp`&+5jlVbfr9Z{ z&~uHja0z@MU}P5s4ka!d9@P~9U+|KG5IZ%2z9Nkchwxo_n!Zw@QBFAQO}Z+pQogz<>eO?78RG&*3~yOHZ^y2c6EQ~`8YZ@J~25p z{pIV*>e~9o-u}Vi&tDj@`{(gy*iam7Y;0Uy99K|f3NYRbM-UgdLZzUOCuDQU17Gn0 zdpxy4wk?e?K^4clAtle9%Zs5RVAvTg{O0$ZYA#VaBQa$=ZbGlHL>2FIeU0sXOvE2P zx=?uc8vV1p+VjA(q~DQeS5-|NB>n`S{S$enX6AG^GNq{T!{pjeNp*9?y~nA=O+8cV zzphJZSUCGfrIj>)oZi6vXMk8SV`k6GY~g*YnUsxD*FSY}GbuDwc6d#sJYLsw2H@pCE2mL#&69kLR1S*)4l zwqnkJE$X?(;}xDS5qIFPT8100g?)6@irF}&I2r!9w7%{!`ctFqi{NPI%WgSXr?zHl zvczJ_6}tOR%jRYuQ-2l%=%ga3cYovuSy?;8>R~;~8*qz1l6ml>GOk;LpeThe0jE7IuVd`yfTZ1vVg@4pXi~Bcul#Fp`bSN~5-mwQ$INhw`)sd%3 zE-GUK#6R4*@v&`#NhqG}21gZE=0h54QIXNPIvKn|szJlrHQ*+U~ToId6?3W^hK@>mOW3@j=x_*gO zye=E?;RCejiFJ^}N&?dT%*}Kjf-fyB-|&0TL>6+^m=Qj%|$o#^bbAW}{2A z+>a~2?yj{RkomY}tS5^mwLVHsc+MFYieSr`-5-2+s;o{dG$O|sxwhtVHCc=z8Q(19 zR*j%sjjGxgVusEvJcFgYH2R&wsfh|EW;?wNIH|ULPhpa*>My}z-8%>KABg?z>;=+^ z>W&B#l3(IGluJB){+&fkj=CVBsX+Nyp9I2S)E#)Dw zaSkDj*3yl$CVDYC{;YVt<|GB(8ByBh;AxfErd8v3+LbSu9^)L1)Pp0(U!Q{* z>?{y{{M9tFND8HJFf}DtMxB6ty;RS$F1wII;e8v-aJ>J}?M}szgdbcH-$0?jqdSJj zg5v9-*h&1X?DIy!CaTwm2ZpY9gy^VZE1r9q!uQ*?1x2gwA0+1CT-PMh9afK>Ox{q+tq;qI(aqJTglwSYU+Sw9u}@4r4iy(LxCAKG{P@(j?( zaG98Krhs05{)i#S2hlPcwXiYe?5mxHCt8isWAW7a`#w$n9MwaCbi9u?C3+&CWR5!6qdsiXCeA~r(vaqK0&!Tac=_#GT-UZd27hGR@jlaGUbrYM=`0&b$(_0#8+^QuKPjF4MOi^6^g}5tfnnslM zr%kNqaGi*#-0h9R=NkIAax(7pSYz)T$;wwUN7k>QwbA?=rPGrUr7ww@zm#>`dl=6| zuipHy#QlMj5Oq8)Dl!%IF&b$tkC=89$em5p=Z+C?OwCj)1@s^xLCixB^xsmg8jfXT z)?FFVh*RHVl=!|e^@b8D*}Uq9W?KER>Q}*1bofNv6qobxfZau5^2k|s zyQw5c%X%iX4L2HB&Y|L|v9XAU+H{XWFq~oXt@5A}Zxgk#y?h>S53^6Fv&7z~Azp2c z=Eq49Pz&PYTZq!(&*997elkd#0Luzzv4dd-Dz8<+nM0;Wc?J2?0g2xG2VG&Wz6)jSd&u7&mQLnV!|ZIL3Ue4W3J1ByeTu{ffJgYG98 zmYs<|W8#8>S2qlYZ8&VV>&0G_iuiK~p_yKxh1Q#XxOC9O&3yCPR-BuRSA%!&nI6vu zbUxR~a6EWftr|6x@V=_IuKoc_9^O=o1?G}y;#jz=JK5Vq-TVe#wFddS$LyedcYF{X z8JXc|GtwF>jVqL-vSysSEsiO@j?>UxNhnpI+|`*^elK%bh+?ft#R z$7JXY(<$`WZAvsrOv%TO#=c^GMr=QQT_rfjKap|lZrk<`#%r3b1u6BGpWIx@ESgn* zfl@n_0?u% zbY3&+o|Fo6$M^1*p8|T|BFmRKQvKDIX(`lWu_0Pn{0+Os%~erWTFD`{XqP0Z4q7+S zrPdTN_0Wi8?{BMS0AKbN1X7` zO1SdTH!Q^J4hA&U*FjIZo|~|5AQc2tSswW#U$1AoVD;#KzMhtyIkg}I%j`nhQGG)l6w;UXS8g1G>@~l zPRpdx`WKf_4C{q1G9ku2H@Irh77x zcbYb=$~WX`$Xs0J7S+KAj}I~|11*7~75EKL^@8!gy*IzABnRvHI$|>&ppn6$<}3GJ z>AFCCaHF)$Wn*y)&R5CNLG={tcqa|5KWS>S8#MganT*u-D- z>r@MvYap#MHhAnGKaCAVRMJ1O03Dr`t%0JsSlVTGKr6CrfH4aG4N)7-K9q8E=_LnS zjE~J+GqMsii|8)fqe--Gg(0vOI+IS5X=siE+^5Z7F1s?}22qSB-%}V_VooQWc{{*i z&{J2hl_a3DLy&HRY-f#x!zHCj_a>7>t>&&I5UXa*V5RN*WfXOi@ZBXk> z%#0}3CZe@-Wq7}9*_?hwt3A^s;G?Uyfku;I+}^_U!J!_n6RS#IeCxB4rlirMe;c&r zh(DU74Y#gvIzSeFTmo(by=9++mg?D3qh`_*y|$L-MjkZ8B%M;6qSE--utz6OZp#&D zHoQtSq+OX#m&@XCrTc+wEkG~%nR=U((tfNSL7xzXdhZz0Z1N8*ylrPdyxLn*JX?|Nvy~qBEshAihpkc^t2;uy! za3`i=NwX+n@(eJ=RE-Wj?bI+q_{O}8&*dplmzt^k_zDQAB%c9!|28SX#nyz!=F1Fw9>f0*GhF+gt>QwOOZQYO@8P7jnnY|F16 zRUPJ(8?b)O`ON6~fUc%;&6Ps(fzgZ`yakH%<;{-eoMq$M9nl6YHZ;Q5$D2gY);*l`8rTciES(r|!WM`$Qc^jwx8{Fp_ zhv93pGWYy@<_Vj|>@JiNt?i`<+X~v5o4t9_oOrueA`Zy??c3`_K5n?&690t{TP##a zR(0M?`3?w9bj=(R13wlP8qmdrhjh!PWTxyQiD&!z!D{&Am&z88%6x;?;JV60Rt`jfAqt z=QXmQ#LDgOfyJtW`YV?u@wnO+z~N;Mn?_zXvFG7<=h!?`7G}g}%vw-7*`iiEa?eLs zrw;ahv{v13jP8nB{5>h}c~@6tv=a6mW{DqX0JkNaH>@ri(e>=qx~;d69-TDmar4NZ z|ILOI3FFP7W&0@xl@xVdV~T{A8gzz~*J&QC#8t*oWw)`;%#BZv5rW%t)-F>W-bt)= zNr8b-$H>9ntwYi|S{a{P>=jbWey$c4_imbh3{0)wkq&<;(J?ftZ;$vsO2X+ZDuYC9 z4AXHu%lg32OL%lZR=ui2IYHnip&J1q?b6 z<(_xL-eSnF#k^2^+L^4}iO}OXQUOCh^H!w_Zzvt7LEM>G04owCKNv)R{PM~Nz= z;=@4AHBXUy8lUf5CPzSGdlvk}ceHK}$zC;FEi=c^*&_*PV)J3BVD;N_b~@V)qcqbn&(O6{HQ2 zhVPe}oHwFw@!w!F4;LIZahraqi~arM&S1MTXIQqk16w%5Gi>F&FAsL&S6>Xq@*2Fc zx_0NiT5(Dp8ws9ul}b-e2rk; zpyt7PeCZD(4fsooyDIt57#TE~*gmnV(%)hHLKWLfSJ|7>Nvf%;%x4jN<5qB|ZL6%~ zb<8fEal(7$@eINvFW4)kk%{)v8Ri_Ta@D9(wUyq%l?nV*4VjlZX;}oc<(kT@>O|zz z#_8z+Qaq}JE(O{$t%z^CbnC_mVcAdZLOidneyNoSmXlm#dgte+Z)#hj;iGDj`v&d? zcfUKn(VB8pU!6b@G+vK5o^0eu)0obxDnnh?_f2hO#d@af7?KPRzb@y^u{zM-D)T+A z+pr|w`cmNscW@TM3uh0fCbe^?GAzT(*4|WCRD9IEt^Itu(K>xvXG~N&fiQUMt3Y@n z)s;F|o|2J1XsKqIOqBlZ8Pj-+nY!xyEc;PW|5YGp{$oU}u7zcJ$7K`;;oC764gDbY zcJ{|-K-9*^Yt2tegT}ct3#E)VlCR#c<~G_)NhaJW86#%D#T7Y6r^y%H&7$jqlaY!O zlo}Mg!bJIV`r;|1FaW+4WJPMRQ5PRp?%(TCwqhQaLQ^ZeK-d}A@R^>m!Q^?CLw%*alRW;~&$(mlN`Pl~7LAY74oj8Z8q zYkJlr<>SI3%`W;H;!yd%_peW11oUjGimBRlh8vScZY!Wh^j+JuO=5@!M|ZB{I@QrX zmf=y*pU6u(W-mN4kXGT@mANW+OE*&|xOaDHWego0+0yIn$0g2ig4yCd1MZ-mC7s>V z4c$@mF{4LHf`(Pa43L!KcGc^?5BZ)tKW#TEE>{MYUq&?ynMm(6|lzme>sjlvj+jMX1cy7?apxEVyRY2Xw zNYB%pl9@VP4n#~$OesloPB?M*l!*VeYAW@-DfvS<0x5G-d5ks-dm*ka*Ysx!MdgQh z3(^c?a{fdUc8-;#Y`~x5{hJJW!l2@x^AY(UdXjz)e6Qq=*&SbTvHQ%VUYkshZ_Hd> zarfJ`g={2BD3s$K%q$8UdlJ(5CT%u_1=}RVAoI)qX`p^ye26vP*X|S5EJKzVmfqXf z_`l4gI~K{&f6Z!lEf$Yic@7;Tckpnn?-0&h(%Z&IICe~!C@>1WHrg9AFkvTgdNDt@ z6v`D(DQ){nTYlKd$@99$FCTrzLVfp9DC|akh#^|m5d7s8`J4`_SQP$wPNz{aE1>ZtSZ3 zq_)vHE25;QmXV`ZsadJ4cx56*`g1ecUuHC99E%J_F~u%HB3axD2eNOt4EEIrF}`bV z&~`Jcgb(>{-#NaYHJiepI{AzExwE`5k$Pl#QQ_y&Xk2blqwi9-HI#;VdzDv&bGgzB z>S`LM%PNoW*)=;=&~|k%mDNIM>Bj{+h8y*+6EHDq3cNg(`1<&Y9KACu-(|I*&md_} zU-iIT{dwoFi|as1#_FUi?R~8+%cYg>I$%~Sedl#XkkJjwrdEwtKJ+E`4-NH+^*N_% zGtXaba&5=Y&-NVpIov-X@ST_V^5x3`$hg<;JFJ88aYkV(NOHAh{nZhnk`Hl~?~8_o z7e@(4LPljCdGf^Z{8;08etD9i4g4P!-bc$G`){D|<>l$X=*9qzz(lTK*p$Y#D|z4M z%3@tz8M&m|g1=mIo@>2Ygm6iFnSoe@%T#>1TsE|oI6d8N!*OM{&3Mw+uOQ!qn%E4g zoR#HFW&I+d!?J9Sckr?00?+5J@Q0P;kX`N{+3T`(&6;0z{DN$K>vY6liA|3pF|ylF zzdfyG&|%t+f4jdYGWv_}8`kMyPT`%Jlw#zyynw9!2ezW$lLYI#>4X+12^KkW{oTkLpIWBY6^ z8-3FDBVI7=$cf7Buf^zxhJ0fQzW*@`fj45fh1PBrx17wc@B!0JfE!+ z9{DH*O$YO*Lk=^zzrJ*{V6huh;|>nHU}Lf?w}vmPcI6)P?aIt`bSY7s-5EW@Q&UT} z@Hsk2r>v>SJslbjHmPSQatsU-X@GdUf!BqqmqIisWE~;P?Kzx87K9m_Pf;y-I zb!rOdSIp4*@X60XuKVvDv%B{kdCX?n>YkG65w;SmV&ky;0~^@Ak=U6GbyYqcYbvv8 zJr%37dyaXu#LY%N)GY6KRTa-3I91l*%mNZrsG|X=ErZ3pfxxE^UXwe@(iN5sC>QVw z8q*oiA5&4}Gtf)8`wO>m67AP~S6S_irI@RN4aF$yt-D#8+ObqeQ7kg$a(+7LWsFaA*1PeTj#wzy`YCz$f z{?kA!(q5b8jIh$;>am#IjuUj@vQX0QRo$B_YFBlp^QCG^xa#VtC68`>h87I1ecB3F zektKaR%r~4?{Qh(0Wp&JI><>)Ek=!SG*FUR$D8{3bh_$jWsW$lcHo)-)g=kmYw;gN z^aGDdy`S=aiyf7oUBk3+dF(zyCaT2aro(NhW5`otqZRb;PgOyONGTI?<`h!R7MGc* zgFyo$xs%&c!MH|abaNzHg+V$`%zi_U=6bHXW_2nOrD;t#ubHiD_EQ`yj+uJ{FQCHOZufG5}cD_>lwbSr6T8CXi9?{mLF+62^=ZIr?&)K zNc1cPX3Ve9C!oK8cQQ2?My0;@0lZj;pfBC zum8}Y-MfIV#Mc^%F<*xAw31|KC!m6Q9Axy8rYah|#Vj=Rc&IWyBleXOloCo~8cDeJ`2k>2Qjey%<3f0kKl2WniNiZF zAgxU`aKQ8b04JS#8&hQn`l&MqY4(OCvp;7+iCI%+)$Q3gJBDY%?ffdGPJbzf$Kmm^ z)znpMNCa{DVg_sb^9gW@6@WQ8;p$1IN%9|_I#%IY1(aa6RB9C_l_rDXP(5p3Ds?*z z*cb{-HaBW+?Tt^l^YvR>H4P5)tgFk&c9K@i`8r5;me$0@PlLED&(zJAzW@77}74xSaC;8K*QY=y|@xZ;brf_kN0|TJbY;5c{ z`sv*>pO1C!Y+KaPG(!UgMMX9fCAVm)=f*)>QAIgCrTm6w|zQ9yzC00f#IB9{p~CZKd#BCf{KNfp#;;4zE} z5so7Ty$Bd2etk*UTf=7Jx3>QP-CL%cBZ|Yp6c9EVidTt#;<{<7F_hG@V&qDiaX?~@ zIAM!bIb(TFoBMV|EesG84=aK}BLagzVLWPc>C#(id@rh;y9E>k0xC)2PtWoLr3Tx; z_CIjaZoJ=LVk)S1-rJ{q{@~mdIXXIQcG|&Ikx!5m`5N;TEp0@R%)+)+FC$7cs{v&? zXp+$uM_o>&FAUV-Y6d{`$4)|*7mC%JMYd{5t`2CvX1;XddSu1*jcq zo_vX`rl$!*i`L)6<;Sf47W?{$n^S24V1>)O06nLB)on{Fgp4+U{U-CTqJ!pm`E;ah zrhr+Ykc8Ca(tvq=hMYj*(c0P_cDW6^kcFVcl7|X{4KU>|7=m zI3dOV00_hGe>9lNqN`ldOj=oZQm9SjelS}*Q60J(9d#^JdHLXE{{Vxn$#Vg`mN19L zE7?KAKhIYT3J+eIwhzaB^Sk!$L$NbhuFb>Wb~-OGB8l{T>b_ECs`Hp=wr23bWHI}D0X7>WM^}a2m6i0h=8t`4 zYc~COW0hiQ(^>tdB1sok@iC1dwTn&a(^+CWNU3F7a0Y;9{2eI*868oOt1qXg0Di&i z#PnsWah>DaTR&&)9>>gYz4Mx_-dRnHyn2x&sjS=Cjia>i)%aNRb&EqZ(L;gT8>J(K zsy^M_vGGVtPdS8_Ah(wC7(}t3sdaWWG|~sc$A_M2=4c0>RCwbix~o&i$-Z8+=q~C# ztG~Kue(lT3 zSI0xqe;?^5$JKT&J9T6?4&}hnZvM#JyBm7tFq>bq`)?gbLTWP^IPjC>_YU9P5VlG< zvEnR+H4St}!t~`)N3()s1-vMbs9*qL!+@ zHoC2-q;K*{x#YyvS5iUuv3mSMS9pQa=TIPNg4g2*?8y5o!oO!)(o(8OC;eP2$E6pP z5;X7yz+qrGJaJ>`TYtoTed|dhhh7GR5IFVDISC@{4XV0HkUbyA%C5_gZkJF;2I{f2;jpZ&>7*x{)316mVEv zSdn|1lHb?cl*Mbus@A5I>(SKRi2yMi0vC`NSc2Sh!M%;G?w+kd*7S-dX`Z=A+*^Zi zc`v1j2a+s3BaVKYd)UvPUKJm0dXD7ZPb3eez)%*}B!92|^Y6ga8u9-CSNe}$#YQqZ z$RbHOAlQ%yC2Sx&EKm--)PSv#n%gS3P5RcqqUSpxu~o0J#jH195ThLb#zi zztxVc)Wr@l(|f=T?`H?q_0peD)IqpaAB$VtnHV6@^;I~)^Xf9wE~@~fdaQKZ1+l;C z^;_^g=o7@BFYt9?Q@~@c%_@Z+onD|6pP(Z8Q}Luf=lXl!m>^@1S``^5<^Hd;s54Rm zbmIwyogK6V|?)sQ% zq3YaCJ$i{#j7c>F(f))C_+w+17~_ITil^SNe}k(&EOaK#in;SQ`Q@%KZ(CHzae-z1dD_PVTG? z0-Dvh4xqj;1F4lbd#eEKbbu6>9HRRIPmah;i!dMt~Ia zsRha43y)3DBEXM!Er0^~{k?bqDW6_FVSGzEqi9uP7#%=>%d1Ma2T}T1`_{hZAmja2 zr(PnosL$;CI*zz?j9h4LIChTV0*2LXJeJkXh_}6~lUnen`j1+rK&1iy054q(T7-T9 zx{;*aO8G2Uh2&h3{v7+>qofm>{{XA<>)JudJU`X@I*TSSX;t*FM7r{IvW2tU+T@iN zB>p|wq#?d8+I8ZfQyt&x;rhtTUv8?Q(=G=`V)Cd>m=hDD^NdEvIehueL*UF#I z{aEWnQA&U~|?>2Y!N_pM@-T2{a6`+D|_l24cH>k-BR1Aa&06pH{z0gdlw zPDl9teb_ZkKsod4#d1mE)TnVF>p-r`POqh`Eq1Z`sQ#bS{XNv4<^$m$;r{?v8g;s0 z5;OiDrmiG`ZwQj$s;L@J9FcMgfI+wV{@XHO=L7v*K3!NStpg5#F39REwqGFG&8PMl z4Anh5$1QucM6|VVNXS)Qj#BYSn?_ONsv#02y0)M|4u?jlbKT|g{{UCdr$cs?ITikY z>hkCzp{=3ZTbun9c6K{IL)y4XSTi`1rwDXjDKE2edCTw9K*VN#h-+wFF|SeQU*O>D|Yt+TX*47>dSe zUotD_Pfz0WA3ljcXJod%K{51OejE|V(NW_thZ~H{%~6V{XlYg@s;k9TQ_Lzc1xbg*Zr0=4Y{-OeISy0Y38iPV+0hm;zW*VCcTBVQcn_q&}16%#(y*F zX-*w2jHr!alLP#e#(3lWC!a0QZhf@e3cuTm58buT6v$->0;p=) z7y#CkuXTLs(tEkI+qqH{)BreOQneJP+IqP;GdRjBc*4as8chVLMURqGBYoM3&Q#6= z$EjhBwJ|K9llix^0Hk2HEB=0nbe3blx3A0W=~1?n*0SFaGNXjJg8LMAK*^RI|_;wU1JJV)+Kg6^uQOMy=IS7HDx z++W&h7#%s!TA{$N<>}I;E~Ok6k4amNN9%3^fo36Xd*1weWV2?Xy;BuBxb=>lM?)M? zQAZsdG|JJ_(@~<*!%G~JNhE>@;Rvz$W|gF3IWEP%q@Q~wNuyMrw^li?cTKcdtWGf3 zCNC9^#f-K_Y`#r#v~W?bODj|nYO>1&%ZO5V9rTu04tM> z59R*HRRn>@0AuCy=s51J?XLZ#(J@bxG)Bjh;wkMRASZCIex!k}><>ZN|l^+Q>Y=3(94 zceZHzUwm(xKEUaGO&&iFwCV8ue?hmiz3bR}hLW~yPSD9!<_sAuhR(ojy;*(1mzN!k zqNuEURDzZ6&ka}*l z+8c(WJ&fBA5Nb-A&kM;)o-Z=7oR|iD=c9n;venVk4BFAe;#N&MS{E|e-^aK`3LC))XN(%7#O98ue2CO_ zSBm|eG-9K85x42c*{>2n^c6Zsl~2x{4ZCZ!tG}1W!wsL3**QJQpX|Q>0Nr~}V{LqH zUlwtDXK-&#nV!k*s(2xf?cZ?HZTzOu%6QtOtJHXDuYnG;Q_E+ubnB}+EYh*hve!btZ%d~SnZ@zbi=gq8D z@z*X^DJg0zH)ckR&1IH60IiA%tKyAXoY|G4MFB`|*5>x=ODR@`i#rJNW2mibl1W;X zL!VF&OBLm7xUJZtgryjhrH`PlXipQ;+hpZ(IlQiKVD24@lc4Ut%gk(Dv(y>f{(iDO znXtOU4~u=px+$^!iB&h<GcU`PZJiX4wBn@OpGT*zwv@enUx@AQ+wFa$ zwmTZX4MVd&C+GL(YZ-yr9c7m>)MoeITW@Xpnp}=js;7M7T3Xz0cDW|aWeqaxkrjf0 zCB3kOKZ$eVLOVt%)nY=nJ}OWpPH4I4s%aFvY5=h!w5gy2fZ;(};BctVM?>SEa`c|} ztlqo-02#ctrC#*y>9-aq5e0VU!O-qh#BFSaMmJ^bs;sqkQYf1zxT}_{E+UFCJd##P zEP+xu{k=U_L=TM=U0zfJEl(iF%g&!Jn?@Q`6WXL#9)CYN0gv`iLI=qA`1#d45O03! ziyyQpa#2uiO3mv}7nZ+n?%9iFar9UW-W{v*c+B$FxRHet80ug~j#X=-7XG=Rj!7Ct z@DSu49mbTW&wxBvk5^gO$%dTM`a_0C59Tp(u5iYrG0X1!x$$x?Z6;GEO^>(;fHmK~>vr1(p$s)8FRUUNXoO*R1uy({d0~NOR{#sma z>Df5=7b8KR#pJ7A3=Zedttgt3ib}_!lW^ts(@cXYD5sj9X^RD!iri{lzT)0lZsM3Z zkylUxn5LqH@vSLJPyt1!NGG32iZ%Ys5(ya33X1sw=jWb)xntS;n>#)W3Ak|Lf|5CQ z@!T1@g~EUd$vh~1E9cO4TUcPjQk-V)uBvVS08>eW-1}OthM}UO%+c2h zrol^7R}0A{CN3n7PbD#YcZL9u~SJ!lEGCP+8UaAcAB1`u zamP&iqipRx&7a4%B)$?;Nb_JSHhn_S{{RHaQBx&MG@~BOO&paOXGIAzS{T95H8sP( zTQo4wEeQ(MVB)n>azWum9-SEhYj-8M{46MR&=6>7De6zBmmZUwdwA}Bm$!!h0IR}2 z?Z&=Bo`Z5#ZJN4VKIO-5YJ7r6nW5c~vgpXuB|J>fX$CrpLYMSvCwF$EaYnTcftV6C zsUWQ?r76O-$>IfR*R8J>DFlhA7@J7|A7h z-dtPVyGbyWZ#+OLAsI?kd19Va`+88~vVC`v?2Xmh+1To~_g8KD%tk9?b<~s*R%AEz z4SrS~t++Cj)m~`p@f&{#^-^VIJ~=9;hKwIbB>SrE*X_2+YBx{!cC=$L14)#yAp-m+ zi`qvF^p&nQp}rs+VvW4EV$^6zUo*##b|@+UJ%9hvtGcYGT41^tY-YDECnZa{GTmjn z_6(bz5mijt?c29AH1pEo;-~wXm?`P%@lpzk?37JTLQbHpfVeAvPDu2olPM!Ul^Gu+ z=sH_=?;eP}G7-^O51N{4Ip93KPub87vbwJkv#I*W3EA5xY*u9VR{o~!9RB35-We*s zysGQiX{jOp;BU>VjioPx&o*MF2_VZxveL;Ms}f3uq|%}ltEf?cw6FPoN6MXOMkAFG z(H+yc;MY6_I8wPMf%CxWF`wM<^e=htS}ngpuyPeREzy_U)qQi7#p5@o(%DrG)v-Z` z*c-xvs!AMY-k%qgK@}*HT45kXk%N0hERyKbK?HV>S{##3Cx;)3ol+ShSC}J%MI@RE z7o|AUg?Isq^k4T@_O9O%O}qx+$WzOJrCcs65@!oRhoY&dC@3gnfg%`)nmUOjSin|U zRFz`HA8l!G87hK~c>Miq{;YNA+%!(5iF6!T@dt?Yp!sp((3zI3%I%%EHY+{1G4EBM z+nDN|vyi8bI$Tv_U{;#!x0VJBmzxUcQrg)wMQqS?iN=)MDn3 zDT%70dP9?-$3>c!b;eV(-6?$)4P{sM#DGsDMq^>7v?O_bzO>2p^6Sd$3ag<74MrEN=Z>;k zIBlXrthXmfbiS`|yv%77Se=xb`7UTU;=Yypdi9m8ayXjfNkwmjpNfLH;g2KganZHI zb~feOIm+x`-(ohd?!o0}+v&Dc_$_O&3iz_2S@i3#W;*X5f?(W#X_kVG!!7O1z*m+C2-kX zGEEjgEt}kRjk!db?cAGrUVN(}1)C!r%=Iv>WnDE(lCV?D63l=`zK|E&7Xo%NJP<7f z6-#jWc@hBrDjIR?R?yjzZ*K1N%QY$uet7}4dU^C^FuO0a_f|%aXkur}Z`}Un-MJMR zX=J0sWPgWHU}zj?@wGK(40bae4n9ae<|dtD4IvVuGlJT;v1zYDMv50kMSFfK8lEIp zw8ecoW_ShVxk&<~NbDJj%{-5#cz=VVO`q%Cx$!HoDYnmFZakJ(uy-e2WVar78CeYu zZDO_DPE!Tdv|b6 z;{G>|!RDl_g|{rT;wvQcV(MBbXIEnGHG3(ul1o>LKGuL5f^ce04o?-R^T%7Yws}g) zC}BB#iXID=U_rpAmkzcT`8Bz*(|j$(H(T}A-lnap%Hp>jE>Ce&VzU^m)=LSv=w;tn zt+BVUHT78tsqmOuk&sF0B{1V@A|4`2u=a6#F%d@5$fa7ERA!*mY6N5Ar44wI(y7wn z=aoZu3S-emNg({HYwP9J7;pW_*V#&)wX{1&Xy7{UFPo#OtKVDWF%@1fdgW3b)rq9r z`3=Eag{PLUF#bag5?5a9!9@% z14!UU4i(5eN$A0DuadO8A8++tM@?pTHfMNZ_O|%iba}i6Qmy+}Ex2;=L6DnsWOjB6 zT!tHJ;dhKHEjD3k>nf`ZFe?%yO-k*}#J3IMS)no*#R&4D;OEQ{nhYMCNm-E)P!I?J zXcW&IvZS9R;x9}BmZ#o{!+Ii;6?q18I z!sOb%ENG&{<=+K>nReR} zMFiDV)lkvW!{wC{J#{m@aW1EDIXCCqCx;HGD}V)Q&>Px4=Zn~sc&fTO>;&*+#a&Gp zsm)O1vKe&6)Wo@ZJXIDv(!*z@Rc-=H3c?X9Xvj0tD<_Sm7s>I z3rQD|1RzR1krl<`(XCa1Xf=T7B#l_Kenb~tRwQyg*8~qr{@%PZ z=bpO&hAsgh@JH6lNFPt2EB$@%fcUB_{;&2r@Xd2Z>x(!lKm;jZY`&c&@p1GZen;1z zd-cbk`m5HdLbd+@W3JGd%xeJ!Z|H69q=t!(hiV#Kdb#~@$W{p6(8#V09Th)s1+W)NCkeXLmfJOAgED(0k8z!zuWuKHK80j z^*u@9*Cqj#Kw<0D7fqQxAd7H)BHvzr9`rxc3VC(2QRU_Ss`ZWFHnOU*3J3r#tVgL$ z?m#E${SUta0j4_8`JDd%tM>HX%40TV)HJdD4J>RMkZ#xi0B!GiB>kOOaMR`2K#~-# zzf)Wf)q8LWZVLhQ2j7^`b>ZfE>&CKb({&`?{{WMu0eyUEH}|brtq)ruWa5YYRO<_A z)B^@QlqX0k8~sbK*5~VfZ|=<481w5wgC11s3&b2M>R;^^^xZ%lXw4k3+QD9gCMSwN| zh&DVQ@b=|tfYs~VMS5*Cgle*}<%v}wT#;d7c(A`ef<5?}nx9U$Q%cvYCZC(AEB%vX z^;mFi&kU!KJ=hvhWc9Bs^&HcIbIWpUI64Wj)pK%4w;zG-rv!n5I?*722Yz58# z08Pq^H#Z;>NESa|$@jeH)2&d{*RI-$7$H<2ok}$V1&xit=bxuO{8)t`U=FRsl61OHdzQ^;i8ztyZIm{{Sye zLXI~@BR~x3>;SpczomujG`0T#Pr8;BsKD#qL?14*CNeg;{{R*|{*Zqb1B?FvP=6lw z?l_ZQduX5$~wBqrv0>Z_=f?ARou}efXMEhv)L^M4vzSuA1eb5?N27fg}<| z%yzxO9NE7&=iP%=hsb*M;s7KL73w-;&#TiSTn=<4pX#MM+ zvecqu=}ht;A|FGRSJt4b0MRNdG@ddpQ-Ve9&$h`d#4oS>Urr`*O!OJg*t=%DNkbM| zgh@4B4FQx@B^3C&i9D$_ZbFi&g0K{<;uz00f43TMJtpkF^Fvr#@g$h} z{{Vv@cfS9IiZJ9e`gn~NOu z+kY!Zj>c72VY0O}aJ3Z%{{Y)~i5{l1iyu7ePnHi9g{rHJ?9sD&3XtA0jzvGFSGK*Z zb3;%+5FWl`9TYm~X(4)~vXdtvy=2RZWS@e-C*Fjgjf2cY(ZGn$I_h7|_JA0kQz8{Jm@C#=SAAWt&i5 z_R~>b&_+D|Tu;lR6~8uq^O_Cqymx-(rl{HZt;r@DtB3(!q5{ltvCh-|?%`%;%-mHz-$R1>P7XKiRaiOCe{ESAAuFtM>- z2D|~|_L@`y=hdzBH*IcArrytP9Ih~97aLzuxiD1KGNm0ZMuDfHr^(mU!;XYBDOCUq zK;O8p6lj9TRC`@-B+jiQvHV#X#Rr-DeSJP%QfOi%Mjx~dEsXG}rA~9@$Iqc7WA|jU z?i^M_b}`FV;c@xd-y4mqGUO)7Zw#CmJ-j5PflUPUmDH5Q>0z1UmXea5M_^e^>bD)t z%)o_WN7!lwJhSL{b-C=*!z&r4s(pf)H9SQtnt6~0IEyi@u8)@w^72mu=)>a*@^VQ{{UC#)f33)o*L)W-Lep>;C`+9@=g= zUYZ9RtK8ouyA!Z>8}95zQF`+?vbHZ*zFvcNZ|$L(uA$1kZcA})OfkmbY2t)d)<@wr_9LbkE00=&*F#^KxVR*OEhX4coLjK(>Y!qNT{kpg zN9cmgjZ^2sf}WHdDd)zy=#Ta0*4x{Yvv<$k?dei&_qO*hRAWzNZY{xs-S`?jon;nA zr8chEz_VxaHMy!drKiWDs>1|&*sY@{*>v~mwq81-BF@z!GBpvYe3VkB_!grqI$344 zeMQ8ds6xIqHvkEt$)zj8zq6&kW%kbTY&Tf#s_a(O+FRqUa+ux6w6_vv=3H(AV^5N+ z+S_|8u;8!CWow^yZTxj0j+-`<%~Ru+N0y!`3wuWUo#fUgc#L4%EW#{z3jCNn9{?a09daD%CYzV^Zor3Bk`yi>7DsrMFj}FhZXyEof8^Pa<>DbEv*wXL7h) zPU`LL+0a|7VCVZQ7qmAYTh#R)R-1l&Tg7dR#PoY>FI&9eqol6vP3qXeQBMxv+L|b; z>ndiJxBR4Sw6nvS2pvYVK6Z zR&HItvoYB0Ty0diEi~~^#Sw*8?4_E!+_$38Nog!UM&q>LRaT=UcWXa|K&PKnd&W^C z%N{@^(~sInrhR3%@l)1j;Lc@p+n04`F!9w_OJ6=m6IGFV`D^mk zAuj};5nnfs6h;S6vfJpVW|~lmrI_iA0jEyD(R zo(Wm8g3{8XL^Qm%(pAMCh-AB(+z4ZcY87JMqJuR|)R1dh)}3C`!EWT5Hkh&xnn7_}~xdxqgGB0DR1UORbX3R_09g{sC)Sk>E+S^?>fwQFh`e+il| z1ZPW{7t_H7cvR!ppu(apmktht4N@w0-u1`ivg3T@&H_x-5pNjkV@dJZ+8TPpLUz?P zOr8h?oJ0`DLmghysN5sCo*Db0L|p(77@-venpap9BoA7X*3xSoDomRhURCW8eYwrEyxoC0H?aWSIgC$P*J-xFj;>b%AU}AzFE@L+IYduv& z!|mdVB0P-egL`=5Ytd_KaYG<3sY1+XMR8gp1B3+Ai5()iJC)Ae9Ias++aU@h2Pe*h z%-8W6A5M|^9kG4b+!Pz1WPMG+&wE7y0`2*8KkO zM-6kmPS1*lof2$r^V`+ZWki(_($m(7uyUcLdWy`wLrE01br%Hz0iBdtExWy~(_1S^ zKCUX&3bi~8o)Rej1l5OJ}yM0Q^|v@*kfbo^kzWSAzKwQMtEX>!;62weWOVip|+cG!24r z1X0mo_GK+XMNGMTq!h}u(p71B8c6*holX(Qxx+5iYZa}tK@_qTSjg|CYFvT99w1;) z=c6mT10B(6ZoZ+GAjUzEC@{JH8qoRI1EH#;bQ*5+$L)>E_0=>v3M#YLSwx2&1|EWu zV;Q{eI%7-<)>4h9L0v`#KnwR3LRGuOmS5opgx;tJN@*bQQhdPSQgVF!x+NcH$+i&*a~4U@;ZhTx`vr8((MBRAQma<7B6!#h}aJvN(t$#?(PHO4PcvDGbE2 z$jhq@DgspFfhM2i>A2-ha)YR|5mGB(unr^>O#G?Cq@QeOHg;03SYyCqFj-vQUb{7x z$w7*g@9n9I#>b|g6PcsNQ_*6f&q}j2^$8e`6oe|YB&Ex24~gO_)U+SzYJGlPH=w;D zF=i#Q5FR)PHT@%vB|9G|7d6 zzM%+`@c1EnJSXezu#8wC3L;$T9!KT=O!V>a)3^iUHv{RPI@IURpJ?f6w=#K+-?xs) z-`kriHt$u}%_w>r*k!x4U_~C5doy&8AVQ7$ z#+q832mDtW)DEAvl@_8!X5uMdFVE+YMru7e_Q}Ew275n-mc1w{v)F8v8fNzhp`MDP zb=AoA_1L(jE>=mZk{e^46+8wiwP^nUNMchXB3zi`C&D6 zb5_fi%~E6Gg=?kB)l*l}*EJ;cuTxc%D7Jx`TbfzMrZJm^d$4YA?PO#K*{MV4n$n)X zXGM~o45*D*z{Py)k%oO$&8lEVYIwyuQ~sq;Qy`RB;`^fh4icK-mN>G(G;V>OKa;A~pR zaAz5Zo{pD!<980?Nb@_kERHd;-`-lPDlBx$lhl_=XvEs7q}B);x`I<3t;BD|ihz9- z{IN>XjB8#TI#CVkvP$ztTR;E;Na4pGbg#;}==}UL+Sz`>k#d<`x3YI_4i~X;vE{P6 zr!z+b%d~Rz74`JpeT|N3D06tM-b?s%H1NwDvDDG1l*)||_P)v^DT-+dhVLX~l4vMJ zFaSJ!x}p^kokp$p0vP0+c@MXT4vg1%ZtamhH(XDd#z#kii(>bN9|*gTA3;?mR{Oxz z;wv`J$fo;edTrH%#N=jHrxN)>(~;<94ehYguXV_rD( z&z?SASH2&&u+O@(dA`f*O}UlWl=vO1vhwvYQ)Q!llTB4yO7Y<&{r zrV&i_>rWyWtrL=^u-vN0;~Q;u^0%EU+rST(pP=*V*3%i5S^RWZK_aA3wLb_0jZYEj z=h5KoF6isc?a&=*w=kQUpRcysZz`G^O_{p4WnWlBPg^w_CKGpdPhHkiW@_>MRh-5w zG0PSnKyr-oN?y`6jP7nN9bi@}lJTV~5RB24c-P2a07p+u#%Y9?4OSo)C*_fJ)pFSg{M%U|&m$kR84)fZ(`xn?- zgKyxdDkwH)$lGfPdkVqnx| zXC6l+`T76=BB#=$%d0H|>HxIFd1D_ScptZ`X~)&~Z)(f4s5YMDq3TM`#HFc4r0(e8 z#`f0Q#pkiKG#jTa)_Y4MLrGklx}JPI^pImJD+L<~ogojVJ1mkHY1S~nA^?*7JpjGC(75iwrCqVneTCZfy?NGquQQ0x;(KZwu0v`~ zys`N>M3L_u?Il!_VmiNYR4r~l7l)|(id-d3nx*kWIV7q`jQ$k%y^$jtDK!G0GIGX% zih>0x=T4dMl0{Dubdgdi@YM0gnf_jV1(bcU+I?ByJsGzCOKr;C&xxNmg6s~b- zXfwcroKOyv%i_fGuZA598WBp@%v1a%Vw@?}yY3&Fy-(7ZZT|pY)Lj|5>NhqEErs2E zv9+<=dVR8RI8Donj=ni_2O&COaGRTOZpy{SH-;#vvGe)naAF8u$*&|;aFDYY%?QBb z!k%a6j8oM0sUnl{Fdz@-<@t06e2MvuyuLzwbIInqOBu6r4mNUUH+J5GGW8qJthWsV z%}2Yavh?*vmX5DZKB&Qqr&(HR`~AR8Iu%PdE31KK_ZF1!007pi%jCm6TjVka9+cYJ zEVV_G^*($!f2;f*6mN{bF?!2xZOyT?zCdn2^oxh2>wU9>*c)4N?P}fB+fye?Us*ue z{I=@=<3Jq0u8C1t+oEeK=uKGY^7s;R30YHAL1!^`$m40M|0kj(?c0bF{K>r=x# zPeY)PpI8?FiviEjT!OrMPx##5->Nv)t@f(w0yz3fQS6vfSL8`iTeqZ|m! z=lxZS0tfXUKy@Fdy@QZGU2Gl{_39g&*k}L^OZBydt!rA>AMMY*@fkS(09X2lRuuBd zr(7<{XVSq~3tdRLL!#Cmn*urK=zZ3nNBv)KUgApQtR=7egsr}uU0YJ~$M~Oi0)l|` zy8h4gaqA40RnjeGJlxv%1DkQkJp0%g9(n4u?WyYvJGcc7g1lI&l2i~H%ts`SN8{eX zK3qT5`#RX-L8n+HjY+Yy8(iF2+V?g$xC85NrTy$zRw+)mP(qrTbsWG7n+u?}p~x5f z7O}Aw`VKwpmOlyg>b0oP5_-VUt0lBqxhzBa3V~%GL1#Aq03UV))}Z6|b*`bjLfU{O ztUjRH-%FGJ7Qgi$k9xqxIQ4IfiO*bq+=1yDwI9-;g$zEX{OKfpKEIECBvzEgI;hFx z*J(}Z0aOKy02M*dq+FYU`n^P-@jm5%X{`c>|gHYH%E~qdXmFt4QTI0q* zxVRU%KuNJb>y!Su_u_q9{a@gVOu`00ZvRv%6Y({D&T7Ce1V^!j_CIj(9?TJsbf zI++wEJKpXQn&_k!B!E>&;`Sc=X}}!gtsXxvxiXz0PPK2LQawBypUsE*-t0l{;a@NE zb>e<~zI{c84R7iMfX2ayTbmn;3-B+`=jrc6)OT?;>%dciAMAAKlnU&u>VVqh52Byy z2hdvIUvw>>pI+hib;_q7BSET;!lF40u>&rMW^^3KF(HFTBU<3!-iuJ5Q;xQ!K_Kv_ z=lOI`yZ2||H#`{nt-*@NZNIt6Qc~AuGc?ghi^f!WR!Tgt@me`)D%N@H(kKkZbXXO{ zg-Jv0La_+R#Q^(%m&?!mJs4XW1EtFkEa&W}^QAMxpuYjQ^H}`;ZBg zPhU}3l$mN=Q!%HP6GM}oDVr%hJdLeuBuN^jZtekHN7N@m#`q&1pSM4jf0lZ2fn^LG zk!w$v4>9xf96FDY?oOYiN%o~Re3I1Umb(Kc)!%rgOkeSO+GneS4_~;mRF6#3(0Swg zNT88gs5*Gk-uzglDur?dMQA=)@;+7T!a3?BBmyXE525rPybXADP#-+DFKBc9u; zcXjUAI^1}u@i~0OVrNqWx$^XYL9~-A0FIL>*O_DInv)$^jA`p3jS^HICk0!SG=SVk zT{RT|`IGT4nv=kRgT(xdZRL%b8vxa-r-&oed5=2q>c4Uwh})+VlEG)?t;BCSjY5Mh zLJ_d_Q&qt9ZMUnbrt!^9=A3J@$fljhTRxE|w6Mh)2(yCFpFC6&C~NCqUq3FrJo2)z zMGGj)A0jw{JrB#vpr3c{td@6kK5KDeXr$jeS8dIf~`tcJ{|Ovc^aG18>W08*q^&!i=zK=R1dI&n&T zRp5O5^Zst9P^QMpe4E2&cHZD!hp;lVTZetYPnD;qsLer=>tKeP1xnI7wN+N<$!5$+ z0%|o>(d)FDZe4jy>nlu#23A!BWPnr1WlGUol#qC(j;Tb%f4s&BvUn>+CdDJEn&@_Y~V>B-u=kGat3) z{skRGw6BKDyUe-0y)`7X#hUQZs}w~oCBb+kNmW{@UgdC45A3m3>p`xh@ z)XAyg=sdGfN9XC#d)TzqG44&r1bG~8)ULwUY+OZN)M=%YBy$pN+B(h8lc7pjo|;TN z5Y>Iub4bo*mTRLjf(0kHZ>V`*F{{E~o~qV>V-*?n{Q9z81&TPlLP%puSN7M3l?R_j zvubxXW}|)Wt+^IgaAj~56j8&M+Blj~26rz*Pk@d+yA5Sz74p+%Xe#PsNvdceKf9vs zAS5V9D@qN$v<%M(WvYTIKpsZ9HNhAFVD!2J8<(0u7UG74@c9x5#Rmd-lhC8Tds}j1 z@EGdsj@`h~_M_X6o>`xW3aHS&FPyrKF?t%T+0!LgfLFdot53 zk;aoqgpy4uQ;PopfQo+8>(Xf_2xWN75UMjz%9Y@2=r{^=etKiNcTU~j8&5yj`J*(u z%(ewKc0Phi>YbO4z+wE2l*7w~picrx;7H+4H2t{h*EY9p;5Q6(SpAI+UPdpwYAV|L>RIvhv{FL? zN-1h&sUBCYyn;7H1;Z;J@%F1hZA8^+PCq>4bWJ2orHrIw)Am$+zI-|s{{WBCtg+Gw z(pln;SFMf-14&Aez-B`vLh>R`I6!$HpX=_03CJBMDBON{uTXQf^G}7yQPI}Tj-M-s z$WXzPiY0lm^5kje#frK_AcI{;JxF-<0;QA^2=}xBTrn909DYZwgaq)#Y4-X4%6`s> zp3eSR{{R_weoqxO4%4s1VfIyjUhYn~7##Lv554xT{{Y*P=W=`Ne7^dMU*oj-4$#eK z800CbYL5?C#O(zT@BBRUQj9HtgJ6LuE)Qc5d+Ab=!`oCz{J-wrtNn8ldkTuQXUJ zjIQ5yV55ehGtH>`ZsH3YwTb10h1_x$Nfn4;T2K-`AfRahfvOr;uPmBOl&+Ql6|ZT> z6Z5ZjJo*#xF*r`tVswsG>;?#$*sF;hw4s-;I* zX;lL?MO=uXZb|m!vc`&Ko<&w;K~szZNj3BydCdn_w^c08>R2fKr{|8SKjvR%Z4LXl z>ieT@b`&_hwNth7eQ%KLZP$dWuiX1~rxl7dnC01c9nj*33EDl8lnsf;Qg|UsdZl(# zGe{+ju-!*}d>}%}4W$H?T`ub){6LUtrz@y&1RfnMx-r{YEQN=OAT*{zn$!$@US7CA zDs)DEE$sd0*;}@gXZK#`$l$X-#&i3FvpX|6Q%#oL{dLyR;^^x4V(-j_RJ8frPWhvn zmKkUh{?af&+ zcpz3&tJL1vjamhgC!NQKmeZ6UqW$GaYLW5 zbi*#)-P_D=s=5}#;PDhS%|jL(Rx}ahvN=kL3g9ZLX(ygZqtDGxKiR+x!R3J!p2|I$ z!)(8$B=1>*gHu8UMh~F!1pNMeBYAElX_HY9t5E%;$Ya;bho3^{P;||AW$vt9G<(}) zPx{9+6{RWb=TA@1p-T6Kpsxg_yuG+ECG#3TlF##!yQD;Op`RT%4e{0U$%w3M_J%?gTf5* z2B|uK6l}HCUI!GZ`Sh`LTb8?$1Tp>*ljJe-r=Otm=ors;zU|xHU$?riZRGm*V)f?R zll>o6?95i!#AJH+aqe7ao(<{PwCO=aYda4sm`P)yT)j<93j~WN*((JyF65abhTZ`q zGk^&zlmMFQbHSJ?p&Tnv2TZo%a+R9WUknknq^q~0zPMb2b2&QqahOWe96UA%6PoT;36@F2wOq7)!PY{Xqwvc+e89FW2hQR3<7Dv$*HN%dNACXr;6R< zZyEFe{zNeRKPnCs;pfuJt9ylZEe=7Y+0pJi6J0a2Hsk`}Fx8&M&8 zT8wP+YtXT+C@kJtZa^2j+pZ)5pt=!S*0L=s%oNlFU_k`a&c2lC%N@!aNR~gQGR&() zqf<*9REix`2aXS)Qlm}5xMkaTec`ny&EYB{sjJInwyiZiG(C$hI26Q9ovp1E!x0Nh z{z|jOqB@w7uDX~L>pQejaW~bnsUVbN-lCPF`V+*H!o4MXE0v6DK_Uh{$ZM&I;^&4A zKf%+)t+nXot;1td9QNbK$6fqleZh>66SiWjlB7LO7Xe99B3h_u6s0u*be^H5RS6oY z1eX&uw@?&54YL~Y)Z8(vjRz7=E7Iw{o*Q{1k^pYeX{v-EZ|Lx;@~;!+PJ+F_*;*J^ z8$nO}A1RrU=iJ!HqH5~u^GPXs41Qb9XG(>n(y~$OSk_ea`iBecmfq!?#2TWE{+Z#Y z6k^ z3+EX07!~<&uSTCCNmWsd!0%OhITIhYYkaXv^l{TcmYWqOJ5S2oULecL90GM4nOSlYC>vRDttO|#}DwFR|EVVfB(~<{->_lTUsrx zmX4NM-P1?7Hm*}~W73AFFG0Al^GhU@yKZb{9L8GcvK83>01L?)q_M3*dw_@Dl$5wo zqq9Sd`uY8sHLpfU%8Ha)up*+L7Agnr9DhEs<-0>3d*trQjf;ZaTXL^=RO7Q-XKzV0 zE_R}ybgEZV?TlXH#Ta9HotcslP^C}|=k3UqUgVEJvOv-_Z`$DDxGg-uKBGNu5{*#Z z%y22^#MZunsq-}G?(PgO+US0|+1rjSvsFDGdhNc)%68nC?bxZg>o>L{s8VemuT6j{ zmWOTZ+Bu1WY=uiLe5o1MbTW@>Zk2i8Un@~Cs>Mq}`01c(Y`z{MT8O@=VzCm3 zQa!yzFB_o9r=QFGzJ9+hv>H(qv3?p4AyG`$zvk#(%;zfeUt^ce?@AbR8*ghIPV~r8 z#3Yw()=Nbu7;U=Tbs^J`tIIuSK)0F<2~?{@VEb{Z2xc36svOl{JN?T39xwAzplyL&FNT zLXcO*PSnw~L_B6vRNMpX%}q+`j2aBDnPb6zd8K`NLvE^NKta$dPuM@t!=t19&ac=N zyEAEacG=E#R>JHIjw^T3MU@)*5`K~CTj7u=_w_{)6zcO#?aT&VQVXDqmv~JxkgDIj(DMxAmEcj0>q{` zRDq=AiqN)x%8mpto_cMrb;%@}SGlTkN#Tzw5AgK3>;CuaO}()*yIXZ`?crAz;+u42 zayu$4eiJE-icE&vug76#PQ$388O?1xqMoZ9WRSctEQu1E**=$8d;pG34xc4bXhi|d ze9x77Ue^}#!&(5!Gsdibc=Gc3bfVfHDmEP<*|pn?a&LXVxpvmhrrICgyd1Q6jBX}| zqA%g=orbQ98%i>BQO8>~LR`IDJ31LWdV^Lt>mod`N(FcZp|9j?Oi+qxq3WXHA`XJ( zTr$wL4FKa#7(Rp7rw!fTbo;+_R_+b6k{hE3F4Q{^{{RrCdiq=gO^%L*cTU_e)6!F8 zH#8J9rKM!?B)@RQMT1zLaWuCVPk9oBb_7$61t_359Mig#sOp+li#5CVFrz7`!1MVU zbhyCLW9RGKhUUoj&rl}8*>v<(d*fNnj*LsE*Y zRP_}il|=QY(5wut(apcrvDQOj8A-|VYEq(u%7e?NBc2IbXo*n>6w*Ip)|ekUetdcj z_Ri$M;HS;++-}S3?4@379k{msGi=rFYCY3U*7X(Gx_W$1@OpjGEnef!Bzorl%DYCW4i%2cJnb231yZochzt0DptmqoMH+prP#@vfICO&~8nqv3kRE zZ|$L9+4Y#|F?9JnMpfuCZ8mazbQwyDIksx#hC;QWf}Wxlm_g@g9mlhn;)p<(H#J7n zN>?B$BBq!m9A^j4y*(0k24v$=Q^b$+{{WV~8;|e)!|J`++>&+v{{Y*O;*5j)r%dC^uS7cr~xa4W6>6M|;GN+M(MI@1`3G@iqivyG_EN^YHECk zRsmB|07M5l;7J~zKQHIihkSnDU1@>Y)Ol?F%g0nE^Q@}f8=r2bR})%t(8IT}`(qci za(I2wkQnXBy6UqObxzFj_$up6VI(anaPvx$5F=GT6Jmf8cmNNe6dttcNeoj+Z0Vr# z^W#zb1v&=1&*R5me6ifr`F_2VZgpj5(%g}M5!?7GI&78$35J&#D|STrs_JN^+!1dr z&y;tAB_=v)sdn$&EFL*LV#%J^UAoC#PB6fHC9$P&IBEEsjd*bBhPEsKmP4=ChYmCc z=YV|k(z|wVp0@Z$iN$sXXX57L?0UQsRgS{mJGC}2h}5S2h(&35)>W3{%3QvidI$M%|6%-7{klgM5e8feey4C5?5&0K#j z9ar|%`Iox8H)`+5J0D_gU76OK&wA&#Ch2{tPWu~VXScl3;>8UXKO2Okp1!852acjj z#E!A4F?HD()n<4eP#qI%xVKw<$;%@G!%hyW)SWyiJgbA6gVpxi$t7^+5UPTeIjBG7 z^3|GjU#jNo{3iIoWH4Dg&hV+K%g4XAT8YyiklG=M^RfO z%Sl>{KFXAc9#3Kub1k*B7V zMruq+q+{WxY2+;w_C8Asc;u4yXcy{U-I-%5+JXNO4H?^=kw{}max-eX{s}InDbp7BwxjnqsOj<{GMM?Gw)O zJ4I7RO6hoY(`nTV(z5^~kSp`__v27CY5xFMr&cLVO6^Kx?LYBdC=d~-0#t*|zr*`B zx6uAS*7sWA=c>?-89hi_dRtZ1ZY^tZV{6;;FYc|6k?VYLJ%2v4MhKDhX&!;ah57z~ z{{T>b*n86xiV^npqr>Oar7Bfy+-kFaMv!^8`jhzld-X8HdiAERDl<-`i!uNj!}5Ot ziMoOLH~#>6_d`Kmwkw=-{{VHj1j`(=#bZ{j8>(p}iv-cy=B-MTN)NN=PuosCD7lSYe?@?TJW`xV9}qv% z^BzZ`*#10jtc`V6LM&JvI#h#UXB?04=id0O3F!yo>IH3W9SXxPMy8&QDXOXHA!?Zf zDN|opFN%(a667qEbyIp{jC!589{s2w1&utu-%qov4R46SVg-144kzp$eQ=j8ji#%H zf@->St*Lf_DVk{3XxvjZQ4pxe`Zv6(BIbBi`}H%ym#n0Pr8->)Ht7z}y1ER|o0wm- z@nKrhfc4-gD_qmAo|8%84UZP$!rxt7k#cz-l0Exbrg$Il^`YgAQ>hS2^tH8LQRES%etqt^tufUId?L3irgOBYEOs!(Ff2h+c@-4?6{KSF7t!eokJu-d#Dq^ZY;yJ2hqXA=)!-8H4 zdYHsY!PCsPHnR|Uw?8qSM7 zNIKFh-$J)Md!~>8^Qr61fG)9x^8Wx=`Fc=)5rtAxC5?PAyw4=Mk`WqRq$CT-B#A*R zpkCbj^)p{ytEIRRiu8@#GEijj2~^9D&0+HJxRzR&p{>W%W2#|z-NhY5Gp%H5k`n4u zRk$|i-J@mL;dHqM)mPypdef#Nspy?c<=;hYA{Trcam8?Z=inFf5)J z)$uW@Cx$$YIDE**ndk=jTY<{%_;H!do4+wxsx7CLpCh?3wKNr5V>W3v zI*JUwaT;fhDXVFys$i!F#ZVi@4&-o(qfu7-EAup`pZaRATZSEafs08QrD`)!Liy?Y z2_C&wE?0h6?)*0C*|hXq^KWD~1tmet?cA*>&Qa}rQKgz$YVg}vJ^WW4n%en_m3d;x z)328q3c#{RlH7Yp^66m8imb+!tqyC$h{&x$$m7$RSXCI2B`ZPZIC_fH?a!x0GaJ@8 zec7{jO-6q?v+1&%61N|Q-N$e4#-q(Bt*3^3Ug;Q3&m|oVEgo(bkNAXMNh+x_5wvlH z4iCE|5Sa9s>eQ&g1xNTQI3LLLl+0IBw3QK-)2b|wK1R<5m-*Bt8xOllepn3XMxS;guGXX5Hx^@t! zQ6~bNK>K)C&b=HB@7a4>ZD#6l9c#5|^EvEJ$ItccCe_1bDl%IyVe8xUn|N=!Y)0gr z>9P6f@s!n4EL5dnimo{7CBzT|?9g zSC89VQodE`$EfSJ4$#<{e3n;a(C(Uzse{ArY`){FtH{Z?GJS)ct&#TK40$7snvBhZ zuYdBNBKRw%m6kFlM74Zx2~1&uMNLg#!Z-n6A_hm1CWe*i>v-LOc+gau0mo4pC+D9& zBcNL|jLPEnRSf%=EqG>EPgnWnM(LbXxV_G zAci^VI_FDh6~g%*H6#24W7Dk}OUZ6@k||92`uwry=6v{c*1MBz(Ek7#-)ARUH-7ZU zZVA(AQCHGpGVoKmiRvq=a)9(7BMmI9J|+5=63EEEcmM2 z#IzJ~)QTzz>K2ZoNh%o{qKX$tbRbp+&>1Mwm;$2;gMw;BE9;t9XN0!-}L5)#f)=OLk*pRI0|YsC1^M zq0vbT_`{8C(kg{+)IzORYOnCuj~+ZfXQxz*9W28r2ix)-f1Q88IP+}T=;w&%e1CtA^Ocwp`kiKN9W*{oj2dTOP{mhvYV&q>*$GO$J(>G^%XI()pi^iFm+&OHABMs){XW_M3& z?ylg&^?vEyFzxQx+%9mmJ-xFx9%$`q4u{>_9;+KB^T=-Ao{&?mR`M~!j)FR8oK?!x z1a{n@hUdUxmUd9bx+1^mZA$Ays|L6fAk+?%X^BRu3wTr&G!^6VElmJCGy6IWx5v+~ zliRu_%}3Z7+c4p@5Ub@Bg_gU6pwq90?b?p2r;Ha?sgf2^Z?`@@kudVy)ZO1C( z>St*cBoRRRf>@g3YguRT%Q9&AFq(*9;8bfd)B}-GO09Z*;)V&)0!U+a#-UcH5X1~t zoJS6qxbK7*&f?nJhqQZ-@_!A;MDsgTYQMD~L1@Nk?qM#i27+592yH>r%7h&*K0uDWEh2RlIcnp~Q#GbO zA;Zw{2clQ=ufL(|3FXc zu+}6-Ls3zctkC{Kv8tEb+;~baE*wcFp;mAyT}G?Ilm?6e^c^D;$uSI6EM}kp52Z8y zt~Kc=+1dWU%xo>UF5=JQ>o>;KqsH}3MO1ro3{DRd);o7?!CQ%saBa%pF5AfNT5JUC zQRGw=k6yfaHSww(H>D}^{{U7z zx(2ftnmw!6v=p0KrY!Sz4)FdRhmx-mCyNn9w_=Kf2xBzID*LZ6 zZw2GD@($I8D1$Nc)N{m|1p~IV&2!Rk1H`w8EM&;f`v=_A(=$c4a1|0IRZNq%c*Mn}tcEl8sVOlyggXV=C55#*t4|u&POKA3 zg5Ff~P%6iO9X8QRET_eXOSr`;LO6fD;MI>FhFm+-{CwRzCt>6y+B+i~xc2Vo+8cv) zVdTct!lX*rs`B{Bd{rBpCxq2oMMqWksZf&CyRWs}#L>KjI8#|P ziu_*~)J7V*37HJWvL%? z?aX$}#N)Q@WEFc}42E+rU-lJikd~n{xXcDkYF=M0LTamuLo9#=ju5I@y^)A6q`P;T z`1nNC)Bu2!id1K`qwbl zGY5h&O5^yCuc;hK{{RO;mh|jw-6dTn*p7UrK6!BoHruAnP|#DcgK6^nhOZq(Uo|Zn)BU?tR@TNLj%>t9QygWganUS{Rx{4F)ODYw zr;w|DlzQ8M&jVZ%Jx`hG6_;a`)>0;+I5j!oE5j8&oe3RB4k|2eYa>}vn5pWVZf_yx zuG%^3$#>@3q+FaUdU36-0|gqj@%l zKMD2XJi0%JLW`70i(6{cppqCdr2z9zh>eqE^-`NJ!;v+?Mxp;BiA#fF4jZ_j%R=fZLynQ-fxZOhw zs%;M>kW5!la3~}qgjSqtO$V1AfX%f*x3ctZtwXqLclIkaNrjtZ;;1B%14TTv)tUU} z>#B62qsA=ZO+6~>4|aFBPzaFEBCwyUR-?bybh05dNk8L60_2KB1MH zDx5eB@c#e@?C8PQL=h5b7?b2pIDXvl$4XAAP1A*_lOwmVw0Y&A+!Z+rN*sm_>u9$n zt0urELmNj$D2Pc)S*EaPnwqjzdWN#<_N@}3OCTrDr(Rol6GI-QeCwlQ1J54kt2P*Y;}W<@Gu&EI*z-r9~FJYH6R5`T19cdM0}(Js#hB zEzJuiMBm@Y4^B_yj~Khq6+Y-$DfyOH}@ za+w{$keVFbC05>|%ANPRH#cEa*3aalqch?&xp%6bT3OZ1lf^fT(?G8N5Z6 z*ppI1Vw9%?>;6;G!owGZNNtvkz-pyQ^(W=i&0QQ5F7Ag5wCJ`K1oTw6j0)r3D6!Za zt7Ku8wz)pyTCBwMP^?by>zYs_XJ=q6p_Y+m2+X9bI6l5#$b7n?l`UPu&WkZZbHD(2 z4=Vb6x-?yNn9SmFU5B)?R1}+lO}4x_b*M~q`*yynN_r~1W>072kk$<@{2 zG7x3H_YnmQTZ*p_PV+V79HDD*J)q;1F5sSlBSmrm7u7t#?sVQ zMc8!M^0+*eD&=CDni`2@l4&Uw9F0z|?z2H0-XoD$CZu_Q4mA2UML@tkH92<=K}gO8 zeK^q9jeM)+_Vg+3-qn4#kKOxQZBg{DCVjz!ZIQS#bsKXHy5!H{9c^L%WSshX-HxTuM!rbgpVL7YAIfxNx>9g6cwQgG4>kp zr=LfYpmvv5N4GNgT;5}>Fxl#^zR94j-5Yyv#f;fe?b_URe&O5+oyyVGMPAE`r-gA$ z>G#!9FP2+I`dxH)8X=rC^ktzruAGn*e95Lf@H5ka&;~5yP~vInk&p0t44#M<&dAHx z`RIFZ2}`mYu&`aDldHhh(7hhs$n4ygg0`ZYKV8>&>|I7bF^k@~ESluk?b+$6?Ic=O z`T`|+YYQZdDhM>GBgvHdQ-|#I=>hSeneG*k$x>))8GgXHH3yHcRz>lXee~~R^?zhw zcE@Dyo~Z6gDL1G0N3ZL%)I$;6Lp6=WV+7G`OpZRM1x>i*#p7yXnxKcvFkiyA`hCEb|;_4Qa>7X@L5~9k5);A?Xfa`f7P+Cfgf!(AEnh!2D z7~#`y!&-w^9)p1O$w&~;<=)=O#a-p#1WZp=kJ9zQX&F*zJX4L0%YGaO?fjoA1q zyq02IuG+2YNZM?La>}%{HFDJtB#{6Cu!uzDNgk_(1nE=h@i9I?1Eq88O!T=W3ySLL4G>;Am&J?Tq}-5YXDy3mn42k66SZ8j$n`So(hD+PYPAYoqn>FA0A+>+}NG}0J}SXExc=cAFp=qKP7~tZIulf>{=XjIgHGE%7bA|OJBA2<_{xL zo(od))=62Ct02@UWGG%`Re_~Q*`yu5r-cU$XUGh3u6n<4U==k6rxU<=eWdWmRU6&u z*c(5xt9K7`d~us>!9%og@XfQScScib60%;5Z*JDTDmFvyO+Y+}m$`?ryEa)ImKQ zUCo2t>9Fv%Rhb&?@3OOcGrK)#e)AQJTn;y7;Oe8Qmm4F?jesjc8~4Vg)s<6Tn#ioc zk1FP_#GWf(&w%08w5ux_8-T+cQlp0-J{b9RZ~azw)OdJsyJku^V-;OaMm3UikI20G z@3<$0<*9<2lB%`|VS*HP5TcYIaK7rzsI75|5A}JI(S5*@tjs%cL*_m}{f4H!6%pOL z;y&1)ZS|hQr^)qgHs`F`+t6X8$&JA_=S?#18vULqVyefibO}zhkfn6dpq4u#M;oav zQfJeFqfi;f2OsB}P}ZD!RrJS5l2#hD)K~cv!k<6e(aYO38Qg@`v{_+@tHB5;Umr*I zFdB|7MuHPa!Iq3%qk;kbag|44V5pYHCepnx%2G;((d$0LTda*g_mvLp68H~tJ4Q&k; zLM&cRuN@_LCK)8h(M?$_j*3HgDdLE;u^|)yT!p{7u_O^roQXBM)flBkY4bGwr}p}E zNcV@#9nFu(Y)r1y>-p+7Eesp7tG6W23X zhMJvFG9+xpfcAy1XC)mi!L=3DQZ*kXW%AMw8q&N6r$?!?#G-Y$y8>fR4uH^9@Opql z5r8O9nds*2t?@^f>+Qe3t3k$7W^nlYizY^vY+TDNXi*g_V`XV;C}(<>@rYYgt80;O zYKwL&HY-eUOjS7N@Rb8erf9JKQZKfMCt5Gz3sJfI8B8^HC}f+PqH^fCjG@#VRsbtSlsUA*fe!j z($a1QguwaSM&D_}#|ibdY+y3TRI5?EDGJEzSkkO0eAg8t&yGHQF0_VN+F}B%(v3P< zauvM6&XV4KRN+p5EQfn-{3iCmZ9Uhw@^x_UTunyx++Bg%8=^dYZ4X`L@U;z@tHkZv zdQ_@Py|nO(3OahJ;S2DN)I7Qif2Xy>N`(SF018vn<>#Nzp#9X1B(BnsW#DN|IL0YKPf7qO!=WXY zfxsZC0RsLl$l&@(2KWB}A9XbHsOc_AHR+N**~KCYlO&sc5ep zZmTzjGox8iBMSmsk9)3)Ee?OwI&l%@GH6=q75;Ru{8tXMQOcEc)b)uSM0E9VM^g+{ z5i}JDlAv^1pnqHsq}_q!eGfkL)eV!~S|3l#rr2bUFn-_l4xFOlZ_S(ae??oJCg+fC z^goY!Ij%a_pIKQ^h-;73qk&><02a5@A9@&|^QZWF_a82?0_x+`N2`kq+_PJs#}_ug zy|zf9>qE;PU1w<3Nh6CMZOCT0vVm{YZ^zL4(5d0a{GC`HIP&W;jZd#h{2Mj+HsGFZ z!S`BMNUxV#;nyD>ug@X=Wn7 zT?0d<2dh~GwtgZC<$Q%nH1tuP9%CPGPseRqAz?#9PnVH3;a@EO04}|^d#`Y2>Sw9j z-*aDuqNb>xx~b{lt;gWjh9$;Q31v`Y^Ez0Pq*Tu!_B#K znNqdpo-AGx8jx0pYew_R)iF#GS&}t$)&XQo)(~`OV25PKzUo-8pPTb@{9S(m*Z04%*(h`7xOJH%2z0K#8g8 z#XOYrIgwg0#J1!pc16;%8f0o3i1V*;uMpl|Mx7;@BQ68fs*Gne`5!-@&!op+b{2lO zd(iG)xT&hH>`j-1siYWbHkC5rJ9}qsd`I1GJ-<;?mzyPw+`Wx~sq$swpsZPG@)Id~ zTSCQAWM)JW24X2qEm~3h(4XmWQ%V|Sbbv0lq+o{zlsKsJr8pliTKxJ?cLwU|?bS4u zG!-3z1`~B{T(;e+r0i@b+hn#D=f}vESncD|lGEv>mtw_NQ7h)LEU`5t@_Cvf0MuP` za`YE4#LhENP8j(b{uZt@;=ZQ67)KmV1T&_GW0Ojcv<&`dIQu#SdS|!yFHCQ|2J*r| zf`fJKU8&jKb5&K6rI#^@&g6H6C0!;HE0)I6%R=?F%N>0@bo9w93if!3^os>iNE}+o zJwp##5tTLn01xHSQP&i*N2mg|6&bA#4sdJ4V~0kUuli?e;rdq}kKGiz(`;`_P3c96 z?#|DGo|U&=125YZ%}cg$SpBy~)8_Non0ECIZBo+G#1^I47m&rE2O7P~@X<$i5n5J) zoII*&T4KE|i2|hv)hm6qK6vuM^FCf(3|&XJ_WoONRnli4aBbeY+8ZY|y|*4yH;IQC zk=@vgEnIt3WIoy3nC}e^K3WaeBd$nS3Ug6g8381zES&GOzXaFXLr#h?T0RI3F+t3G^-5IT;LET%U0@c|ryu)m|ox6Ap zL3(1CUOYZuZ=XrLJ5?s*o$;F?H->+&arpe?eOpD6GAc(|wlIe)yEm@)iRTpiYa5xC zf`*!od5cRud((wiAO_%#m0pw#R4_CYqWS!SfvTQny;0mc#~RUsocd}!LHV8--~jpb zHDW$DRqU?H#^F1|aBaFh&HN@k{{Xpm)-snRHq6;jXXxuGs%h)0o`RxSD|))QWv9qQ zzvRO!98s$i>k2@QIYiQtQoYCUiW(3{3fGDGfz?X21Zu>-s(=TTID^KN_ZKIg0F}&e*9kb8Y&1YPyOFnw6U!Pg4{X zF>0z}w|L?PIZ)Oz#+2Zv$kcEmhmQ<#uUbZWvW5zv08gp;`W*eAJajPbZtuodZ7#R# z{f~ymY>ln9cC2+9tA0q?e10mgd**6dqH20veH2n;RWdX<$NN~MYCLRmmZ;M@M1`7Z zTtKuGj-<0t)`XA=Un7D^;7<&6`nQPg4M_47ALPLq$ICdcM#rGL7i`n+O#Tlo26S%^ zy31p$=Z_yXR1{OiMIBW#)8we>-j4%ML|Sw;Q$sA3Q@50%)o*+6EM(KzgHfQb5HrX2 zntb}QOQn1@2h;rj0PFxqr%0ceKPj=p8{41C*XJ0v*Q=!dAx?@i4?g<45> zF4W#t!mAfcwX0*vR#U-Q4F!CXz?T{rx9yc&-LqOwkvgz&G!RJ0_=BDl;X*k2XQVeW zTBy=lj;F{OVgaD1%htSqZ&f?kU5!PF{x7@o7#e(9XSXJA1BU8)Dy*x@wV5%GSWfbW zfY(+j&(=bdlP*9{nxu_fF^RMixhy5T6B9gW3aLIAs-IKFl%)Y;Do7*e(l*>$+Hlav zQfer8s3M#_M-Vs+SE~p3Yt>yX@(;ImW;c86nre(IblqO+-mA9zTC+6u7|f+m#A0Ra zO|LXC(&QuBHI#RPf+sZ8m5wK#6DpwhNN!qN*sL1JJ+guvih?LO)}YX0r~%>+0ny6Z z_>n_=(#9pxRZ?n8R+ObQmO0O*Kd7W8c7{tk5oZpAyfbaLSYrN$>K1#R24bGuftzFe%?JGSm%;< zhB}!ufJQT)I#3hq)j)j8`J?eWJ%@v@v7g@U{{YuJ&pnfia|{K3OK5Feru^G;OPgQy0Fr1Y>iznVSjoz4kmrNlR|c`=uD53 z1vi5;6?D}&EH-l|AZ&d|rYQ|Zo0n2RWg*^fZ-v|6#%=sFZdPvwsGA*+F-J5IRYKC6wU}C4TkDH~YO;k{qcuu_Na_uA zaljqyGeSK&F0{F|TUM59a^l>rSPW_-8T)*_E7bj+xV{_r2Gheq+0@(r0AX${p7z`u zXEz4l&EITdD5@)@+&g!8=de{&v)1M)>4$?6S{_6x4LFs34!~+_d)=#2*{G7;P?bJm zI51Lw66Dj)wd0PSg7a_Obgk8^E#A5SwIq|D+2{7-rF5Nn@v{w)N~+$#-WctboTskF z?QY)NIea!xWMZrGFvk{Vn|C{g$BGJEL@TPT8rK zJUd>3u+r4-kxLC-76NG~sw!x!WE2(tI&ng?EHIr_QaiCl3-0AsHijF7k8ms(Nm1Ad z8poIw8P!Uh0YTE8mS`b{;%kB=+Yt#MwF#yakHlzy2<`JCy$9PnbZnePQllfeW6bXj znU2e2q{#mOb+<0#gC#{WLRu`MZg%kHW~GX@aYGI6UgRHF(xBMSng7R)?pq(%$Wg#USw%1pu6#BP0DcnE7Dmq-M$Ojgz(ZHZO8ibq)h~ zcJ|!d1uMH7eP#12lG@W%7o=Qn%f;cU@p!rEpAR0NxRPbHG4hX7a`3rmRz-?9-miwG zKm&mXi1P#2@~=qkf+P}2Bw)$*@vc1h51*DtP0?j`7iRBBvb1>fvUdG^)$?ZQ_de^Y z&(~%5RMhV&$hJ>CRV7v{4^K*>6ov+9;etm2TS^aPn}x;u!*M@`zABX^R1w+_;T%aI z)`QQ6E7I+`_PdMdqjBLv5YV+U-#;n?=jon3VcodQ)o__8A;eclMFD_qJPt0WAY;R0 z1v}&Fp~ul;=<_u3R#d1mNi0{XjdZaJeX||h5zTMnOQz;p0jim(#AcnPfZ?C#(uwx_ z+tV<&WhqltLtJsyP9Rs;pnEll-k2FOe|=w*%}~XLqk7s53>EU!3|x}OC0;_GZ>B!G zH27QuRe>H_g{P&as4EaG+!L!(lX!_}!I^avoC*wzgF)&>ah{4Nvb%wnGbFw$P*ANY zUtgEmTJ$t#`tx?}OcV*c=&Cj+U1XOX{o$9(y@IIW}iFE$GyxrCdeHISs5tL$K1Rj||uVXAN1SEopko7ETyE?#T9 zeJo`xq?!;g6BRrusDYEx5hsLS!mERivb+Euzv{0`{p-CqKEbBilzF@+0x_&OKWRoD_o0tBNNF<6bkC63jY8J zua}rUU0vq7xrromh>nEv;gUT_`TkulEA!NHR8wa*7WJpdQq#@+Cnj(;LZEIPyHSm& zuc%tQoOt|uM~SDcmFQ3yzT#QqXHda)l~M(OFwcucA$*jdik0&iuNv2_{_>fbQi`f5 zpc?VbN&f&K;nZ5VuviH3`APB-RAsVNFl2Dp88Q_4Y7MnVQ$*5344YllQ{}S}NpHNW z8f2y{j`wZ>RlUR9&m|%v&;|#zf=D?&ClIIbkHwyh-W@T#gd;Euqz0`k#46I5;%mdK zTX(Rgs*iB(9P2@gY^%XlPm!&eVS23GQ%6x8_zaxpEM${F=%4T*TgbkEEHnLuV*sUoLPHOKIuR{2wvN&gGYvndFu`7S9S)d(kaRV5o5OQSvT>7a>~7t`<7v9x zX)7M4lAOr9c3cazI!sx)a(PoyY9w^L2x8R}}pNV12o zpbMsh6bGSnoWQc*!E^Lr#~cPf2O3nADN1mmEvmhL|JBxuvnV#?^FtQS-A%bMR8+XE z78^5HL0OE*R%51Sc`KpAMOg1cTs>6PDKfK2BN8(X54%9@9rcdlPYx6wwes`nMZt;P z3J$|hDw+>dzBD|* z0g}P){=LEU#a`gw`{D{rr&B)H$-$7R**kX}&sATLZI`x9OXRmB;*wXEuBCLr43ZdP zSezewiF{R1)GV216`>z#^7#)wIq9Y{F)+q}$W2KeN;rY!zdtklx@j8+WMHw8(+&5v z>MF3?IM-KK#A2q7il+}$)fr8dqLMG-dClXNrKpqbYNw4NltN-L#?gXzGQ{9HB)Xgd z<_-ruXgU${_*5$bGg0ajmlMS z+6qjS6m_^PT+&Q_&KM#4bCajY8gflp`xj(J9_aH($JFSq zS#*I+43qNt57}DvHwm^nZ*X+yU~XdcJD;_8hi~L}hSHBAkjZ26QuNLjAzJmY?}+i0 zl(bm9UQ()9<)k#!uvOEzD!SvkhZ-n;ku+)XLY+kVoDa3X)Ou)3;ibB=onf^cR|kO4 z^Bk)Q>^rJ#*80go`JH5=xO;ic*HZ=Ht@OptqZBDjK|{9$#y1?4igE^p#tl zC(g^Cf(l7-$%V)5d7x?xl%wz2yiY7`GCPyRT)A?J?6ML`MIb02#p-|6PMNn-Srk8q zQ}Iyxe!Zte_-o9OJ}n zTm@fc?txU4je#{M*UGu_uSz^UBow#?fYUTI9$=h$(!Xa+nA#z@qN_OVz?AtKdO=n8 zbu&~@W9gofNg$W}u2}$qZcKs@H8Fc0O}(^bFfs=eg!%C%qs!OO`gGdE5!B%cYSfcX zA7}U}iuCcbmv_?CV0TQ}>GlQ_Zowp2Id=>9h_(VFQE1Lk4n{6-U@BwD9yBhi=m5wq8FYjmhq;B~(%v=5>Z# z4MerMjliv{b%ZgJhC;qn9+;W41H(EJ7`qAz4mB93ko$9-bo;Pq-X!=?nj8q8X?L>L?i^ge`){{V-jtu|j{CL;+wRc>OQ zqj6;oX6(!3T9+k`%5{&Dl6={3$IhV(@6I1AoC@IWf%s~t8kr4q>z_8O& zw4iqX07C$IisYUo_2^m`MRilEwLXA)e%^;VZoI+nP5s%OwY#Su5$QH(Pz~R+XlQBG z^OczziukDZe$R(#<_O8;^BeyF9a{x_b3`BQaSIc~tpRBQ%V8abjlh@@bgn3fXaR02 z0nG;%HNiPOQe_e|%_A7401nfdo&fym$D?t#v6Y*{qkcZsZH!(Qa^)Tksj#I?fyUsl z^!uZH(#1*I6csz#qGsQl%XVREDjvG3foZ8Ql<_>XnWWLdtiBx00jKCtuAf91Bq;J) z8Z#Vu4xiLaQISC_Y2%#!Dh@gM@#rP~{`JPhqt0|@%iDOB*jx6qb!~pd+?oBwkEhJ! zGt^Ysm)mvtZO>LDmuJ#t@ZvaVCi|9~H#Aa49Z~GaT4jNGQFKmit7!RgvRbY(H7F`{gJso`lBs{$Zo6w#>u!h zHV-E-uCC8yc7AUk_L6@HrxeB+j8zRMk}x+1i!71_(v0oIaVWLO98FC|Po)P+!EIGe zE(Lun!}F)F4vuwX9ig=IoBsfA)>C!9V{+BxHqLIVGnlV@w)&;omG#h3SL{Jup3_m9 zgR(abDoGYPnQ4w5h61&5u$e)mlLZeHZo0M5C^@f?u1TlFN#JNYQ3wIG2|Gc_^8Wy2 zf2eeKIya-RaP1wV-7sS|Z9i#kS`E;YIljf&mD#S@p}@(N$-Y}*Z;V88&~2GxpA}Z^ z4q}p;YUtpRB5hF%hkP*x3DTxD4Nx_aOoGQF%A$ar)b*yJDhjuXDk>?$zF+MAUVRqL z(cPObskcts>s)_i)UQtOh_W4xCsyM!l$D)z*MGrrdAd5Cw>Imoi#bl4Z#9|8Lz9ma zL0wic)se>lRhhz)#+u&RO)+!jfYA3G@TF_>Kb3sC zW6f^G?ryHzxwvknOc9-yIYoP27yPi54Q7-JMrpU)8Djvb!l!y3zE*`Nw zRcjpIY@${v?cb@6Sdd3j;{*jDjMqpsBU4hO`O_Ua6w%16!0;cKAD5r{k3zRZb*|Xm z6x-?zftJe8*c+3o_MY+Fm|BL#wI^L}OqTD;V{y}(oK-e9w`oJWG8o!5sH&rA9jc}= zOHLHb0{d_ZFav|B&3MwJc~{RB`+9MidmShPA73heGshn)^nbf>=KC8VS&Ps0t_wX+ zx%N&24YM$Go6egNlEGx~7}>WbZkKac?V4F@@KhACF1Tvj${bRCtB`~S*iR6DDbr@GEGrl#&tkdkf3oSUsE*q1jMHlT44H&)Yl3I zXgKw>cuH!9CTA_0ni~3wlm6Qu4mvX3RljR;oqK`anX0T$@Y?J)3o8!!@x`~W*}6G# zO3-GarLCy(($k6~1XUG5%tk_W7Pk++sMA!Wf&i^eJiPeQzMfqtk~rOragssu;&}Oy z`#|INbg7D=^V>E&gmidD!pbZ1SuAuFl~B)8)H6?0K@Kx5Lo?G;yqOqFO5x>=mO{#G zZSQna0o74VA1v_vhx6*B=#*BXOaZ{sylOtq94XQ}VJ0K0_C9kHO_ZRet;%F~9{Z}y z91J*YO$57@;mXrePb*HePKIY(Z9MYGB*@f`2x0)#ltt2_fpO$BU(1bfJvjAw2aham zpy?D8uNqfBFH`6`7xv~+H;q0QartU7(qca19ELV2jXbNEYHzm6<1@4x<@;5~P75R@ z-GhM2?7W&Xg`ux^`oC{U+;}EPh^TKeJiLfLr_bfm#wyql6oG&YIa}yH zv#G%c-9Q75I%S5MspHXP`89&b?yP3Q!BcL%-L_~l`@m4;-()5#n=(=gVU@?_lRK00 zE;^eFTaN~<oh>R+?EUodmR&5n)$yXFF#Pkgb%|(Bbe{4xzwq>QQaqaRL`+p1_2 zNRJqhC@ff16{yev0N~e9;sH==UX;HTH~mI4X7{Cj-^A9%LyE7XuE1{7j>Y9$cUR`p zpZIlq$&N8o?hVC@#<~W?#?hq(EO5r_u3P(dAXX>S@M`Vgf1Wirk*^E^!=#&nrPS71 z+!9#o6(WEP(!UCm__9k^G#v%Kh1gwrxbVH(Sq?`Fj^CZHgr?2yc&IkC>AiPO@558j zZETm^Z9MRnN-U)H*x0e{3~d`x>XLFqMBT5bw_Fd7RtHTe_2HV=jXuf>^sOzfN<|D{ z_;FUJP%EP@D#DclyiXhsJsHibxj(*BV7JviM?H(#ISh8takT2H;oI1av5T$6NlLq_ zTt#=?MU~IxG67|!5V1z5V5L?_*p-Afxg}-pt~0<;@S&k3XQKYqgdhsjn$@XRV^BhR zs*VTDo)|qYJKJz&>3TyCp3Y)tj})0)bi1ChGVtK)Fg<>hl@+;K{JPXfT-ivg=qS<( z$|`U2ijgUJ3g{l_*a=!zl|La)0+p%qBZ29QRY83!M)(S72(4bU`BH?`6M^T|cWqtO zKF-4T#8}C8K5rw6=nSq)tn#~xx~jhsN40X5nGMnWOD#iHk(Vz;h1gg~Vi^jjH4mLsr2>dF7_^*;UA+w}N@RF-i0S4GC3V2e{O0Vro=m40`m<%(pUH23DoHZ5^Oi zw5VDP;+cLPRi{?bwYxoJ+IcP2*xzBW;G;@v`nWRH#&p9|R;;@I{{S|y#gDCtri_RT zDm+nXSv_al?0hvZF;D=_4gmAd`FMGBQ8le3NNqup+M|cWN1)@w*NNedg^DV;GBnUs z(MYuv^PrL$5ly6xqYQ^bg?NaKfzm)=2qb&iKbJ_ESqTobBcf&UOMhZ0H_p}A`3}b2 zdonDB*veH^WA@%M2glV;wb2NUPdif04nJ|#G;2qXNh2|$Pb)UAR4155@(9_a)JKt^ zR;K?|)jn2Fl;srd1@Qy9&K zKhgupKgFEqp)v_lNR`sevMQL0kTs#^!rxT`-pJwW(maA|$EjLP+tNVbaz%%vSO8D_ zivIQX^}14>bYdUbutQ)#KTTKX{d@laUwR-=W3LW8GuJB>Kh!C;#{oze=bmrg_IDJOOE`2=<_ z#^M^ET!8_ApVK0#)~)((zw7(82B0W9+NlgUas0YQZOXdZ$>_Asvf``6saDnU7$!k!X+)7ci(_@T_q^lNH%-cVC0K0hpF8WDF+f3TtrQy2jDSa(^69%hCVmL9J2!e{sbIsyi>2FxbW_PbB3|9g zRqgnq&ezdRi>RlcZPUS2CaR8q5o;+_(hbXsp-@$|ih5N1>O6<}dfJpF*%0a{h6qtw zjy!z4{(Tgxy_J&K*;?Jhw{n%+e{l8YPLl;yx;Etajn0_;o1K!KHqK)sEOF9PX14Ah zJxn91o>eo!SFFnHiaoN+@D*CB91v(i`OuM46w;v9oJUI(tcq4fqB`&*l_IqUzGPN| z)1a&PmUnLMT-198uN_Ibww4L7JHl9j+f{vogs7`bqvNweddcF;R#ey3Nc5)-G&0e; zk3On%p!ro@jAYbimBBU53E+I|z=K28WC{v7^{B-_`v=*^xcvGE@R{$A-G|#1w5^WH z?OoA?f@i3v&f%)|2J*{peZCs9vVuG*)G((tNmA{uvYuGw`*o6^%M`kU5;CpA#XK$# z&2WE(syKQAD_$d~gO`ZNuB6m??w`=a*-8o(LRR-M0P}gMZKe&5# zpKj(R%hXrF4tASy?JS*Kl^cs9Xr=Hd!Oap>$qbCB6|7m&5=dQvC2Dv8Yl3)Tv4Oyo z>0X_AUSU8^amSC$8h&QJTuwSHJG#HGDW#$~%wBV_cP`M#P-ZGOhVR?&TZr5k?Y)qA z@-#b^O6=A-qoBp@YNw&qKC+pNt)EC0q6@> zhM%x!)aJjRNbix2{*^Yi$;INfoexsh(_7@QDRk9xLPUou1^ty2=emi z9oe&IJ8OH;e0%PU7uY5%t*U3*nLKV6F_y{ojxw5AcPjQ4*}%Y*InCYIxT>jY4n{hH z^|YAz=T~XgDAU@Q(lnCLwZSM@qf`p_XB+@=K)@Pb9nWZ-dlbtsc3lV$8V_e zAZEOJB{+QbS623>)yxmzm2qv(!^LFMqct5~TW#ZVxH^e2QDo{Bd=+^N1w9>VWEKZi ztcf9reMP;lWYgk>IZ!|#QvlQ&9tY*Zr=nImB#ptLEuWG906!3Vz8;su;kSQ!(r*31 zx#Hg4m6Gb5ZtmK9b0%A4Z0QTp=eDjBJ4cO5&%e#$wy#p{YHEyrCMTA*qN2K5iQQOf z_StCVXceeKR0cc<28wa!d#JvA4-Sswsye+hKxy+iIP#~N^75}mPkvU-i0w_QyQ%a3 zYX0laZ%j7ki+<*5aGM`zr(x^Ka*AI@@Tl%0(tJZNo!Jj>OkxSj#08v8+_?wwK!JDH&I6 z2LgoRg;)mRz)+eBdefvt7AAtD{52T+3F3avmA@30n;EcvF>NiQxbW+`zD{?hM<3nU zT<$9+n{KM8>umi#{{Tz|`6h}m?MwbVjwSvjP7xI8chEZtNIoGAyz(LtWp zz@>b^6|dqSXHG;(jwsaCc%PdJ`SA0{y zI$1OL41P8#ztQ%{q@>GBx;lbB!Ktmr z_pV{;DqDK)><&j6B~0{q%=YM^j)odqIL&=k6)=`h*o6$+iQ$zL_!J7*(ECfeR=ql6WIzMv%w}8mg>7r9Up=N>Qpa(tEiITh7|gpcLXy zkQ_x%4vRM0r|ehC(o|TehlJj~9qsYR9v3S?!mS%H?qR8t~u2_AYCEJv5O= zPGFlS46k{Jo<#sP95&Y>Z57$XkBX8M(ELKVA$Tzq4T^B+t5wQ3i!`BG7aD0^9|%9l zk1mW4!aj)XKav~s5m$iSnww5g3 zb0ZYBRkZQ7EHQX$3q-+zJ(3lUTf687Vt~X9wMHdx=?d_t;jKkiqllx12nr;w^O9GN z2cRF%SI-`O6|bEC04MkCdy}*}13lE09et76Rh9Uqp?aLI?~8Hda$Nr&fHa)~DQL z7FUEpKmsUi63jT#fMog8v?$?R^;q~#*S9fSzZH|)a_^Y1knBizWO=Gej7H_D$oJJf zE7EOg@pwuqFX5HJ@>LPhM;4^3pr@G1vTEgsZSCfgDWt#fg~5@Nff``*1fM^~e2L+} zbe2uQ8cS!gGKl;&sMTFld5}1P<)5ET-@(@>ylC=$_1HNqM&LEurzKlM*R_-xynfM5 z$C9bs8Jre+mWnB{8$TO_+*3_W_T<#oRMsq0+s7Umg_?9x8yusv8wzk39D6r?a!ra(j#N-o|~rFwELTK=QFUuRk>?qk7Hn| z<=lB3UKYNwDQZ)2;hMh}K35sBXy9C`JYm7Cn8@skjTl*XeYv&E+F9Ad;{XSW=T5%b zX^p5r55TF*5CGwt+FNY#+W|G)uRAqNYPAA{<-y4ya3cV8vx{M2viaC+diO0pOg zpvPuwF*4C+_l@N>%{=m8@tz53^V^Rd51D9T^0HE^T2ohCh7Q`qjPVZR!Tbp>NO zNT60V1S!FDKwHbB)!S{_Yg^5syh}0wkQx^?_=y86@*F@MdJFdq+goby?W4Fhg?<}7 zhOITZit0G2UvWXOE3z_61Q-haqm_dm@rI>iJycS>@W>`zGHZ!}KD7Fqn+X!|$sNM^ z{3U?=LB(n-!G$;+XQGYGHq$wmYhZFtno~|``+Y0ZojzL+nb^CZY~!+L zhAVDkYGO^LNsO(dl8X-xcB-MV)zq7YMUcRe(Z1t3Q+3Q&-Yu^2Q8o(9qFSVKUN+tlnf!W2ZF` z)5{V>u16ZK(7w6W$s`^Go{TUD6Om8=ImxN=>(iyXB25}gSBpDjqmuLuKQ5_Oh2u?bh7?sI)zJjE)da*}tvn86y>}e9d zO1&xzN5udS{7B?YXPqceic=+ln^0?S2anjUHjir=VR@7;P2<{~I=G|mj;M5u$ zSfo-E_USGBw6Wq&3W{{xmnAM5X=N<$sY@}hoiX`w{k1h; zD+MeoLc`z>>+QsR++R%5jNzy=_8swArTIuXu~ou15EHF zidTmNnskQHIWjyDJb_S>L877JGv`h;8R{NaF+r8jggfb)r^=FB-ytT^+RoKpxmcc;SKp8lr=!pW!142d}Msy4AhFlg1AtHNz>T z08ySbuM_A$Z&^34+^XAnU7xn#sHW>#%@#j$M_076lyy|uN*&6wRMSbhae_JI8^)Rn zXp%~1c*Q*;hB2c^6t2|FzAVTj444h^01Z622FVr4{JK!a+*?BR?^ZP&Tj%-u4g;ng zleTwO@!#^|v)hE_YWB8fn;S)xf>DOA%waOLb5~_*DRGn2HAD{%IW;z@f~mfvU=O=q zm{Q5SDAjyL2fH8;XrqpPRO98-v6=$K9ph1jT81^__Ii`#Iu@(;ZqA{?O}ATfe|u{1 zwMe-fgLvo0<0Wl8Uj-zoDi3>U1GxHd zr;r03Y0O_pf)v!?)E2^}O>~pQ3jYAT9;Mc8?Cf>fYHC9rW-gA08!Z(TR^k=%%e8ST z#f~c>B1@9m43x04_$3mkhD&-_+uhaej8>9qV9v~H7M<f_*cO+0)U9WuSl+K%|rEKNrmD z;yn7c{{VrX6!#9pp{?xx+{Dmz-rMXvK3fB^zC_lXj`pm})b0!v%9UMzxA04oqHU>B z5~--l%8_JgnUP9H4Z)mHx>eAb~>LNA>S!mce@*Hw}>M>4} z`#Y-oTdQ}Ua&&*o{B|x%p0U8D_p05vO8oLcxTL|y43(IUvfNmdhD@H>-*maWVV092 zOeMwD$k0k4dpTjuES>8|Q$h14paOnG59%4l)Bd7)iS8%AsBqw#&Chp+AEm{Vf%JIi!qHrCgnn=zK$ z8Hzkk9~m7{o~t*V%GO6mO&k>!(?%+C8M@~JFz%tAML}V2Y9WoEL}>7%Coui7}y$Oq&9Ny0C8hW0(+ zpJb>oPe(x^t1Rea1e=7W5#Y4N>l;at=cmYuaRl-7JsO#GRIg!;bmsv702NL}e7=5N z7>&Ep*vwYM+dHRo^~AWXfx9u8t^J9|OPj{hNmJ8Vx>-dwQ12ReYchL#C$%v&aa73? zv(?r|Jdm=hpLd!srdmiBQl@@yGXxZLhlP zD7Q8jckR4AJb1=6v^Z*c(w?(!ZY+&8T+!8KYjAl;DnrqD%tn@tQZQLTW*m^ll~vHy z*T%kT2=Y8VbD!{ZjU;Val|W)B2a)wO_5FZ!V6e4YvabiXnxZOrYjN2euH>W6X7X^) zM?p!A1hq^X2O@ugWb<`w?vaYgCX@Aek=0ae2L-H%1?*`9^yR@+x{Q3pv!+%obK!1*=js49~AWXsB*M*+4}n0>g~gv zqr*xv8AaZCPb+RM!ZE%|s!U~Qc;%<#{OllH+=pU%1H&TTH5 zJ<;0TOI_Mi?~L~O-g9m})4RI*so?QIGa3gXhy;#K%)( zwr_BC#`fQJyBBPUTX1dc_nt~_%7(KeoNP8GIyu$n%HlE@$noax*i%BlLHgNIi zWYja9XB8MV1O}(sUYao#l{C{^4kUST&y{%nyn1*W`Dr&c8+I@8(Z8`c>~4RmH!j<% z#q52*wQ+QKT!eCBakQ-UbiZX+R~$1`Ry=D|?+@#F+%RS-=I`+ZW@2hdp`id&)5r>i zQJ*Yy+}8pI&={9F2Lgl8e9zDMbY(hotVZ|iOt*11-`u;pTD|dv+nal~w+&uzYhh&G zYn7zjTbFX~j}}3*j+uHGzSvD2IA&U5A@WHb{hUW^(ZLUgL}q&GU@8@SvFqkZ{{TNr ztlUV9bk#W2oYU5xdFa=Ci>k%uYB8OTK0?i#-~E5JEAiPit*6{7`N}MfOi^G?$H(C+ zDdXHU^w_$(*lB9Ak;6W6lE5@l!E%mx(VNAMoXJ`o0$Qif$Qn|E(>)mmQL7YMSMvFf z^-y$Cqw7tDz36ZlUZL3gw!15Ut=s!4G5gnX&w<-po40e-O*~B|+N-aJF^8keElkZ- z4mo0@f3=2UfZ_z1U_@7tMF%SV>p)cEYp4R%8pjgYlg-KEDP zO$AKLER_Wq#;UAik=8qz=s^@!t!P1^W*>>^!-B62@#&Nu%&!norhtKxc<0Cu^&d{G zQ#nT-%I%CcJE=DgHx)&`dOJV1cSh*V)X3GitWG<*u$$X&W&HYUB&y0}4G!d^ps1`a zkw+4-)Ic80R=y(xh*T*exv16lQme<;r$U1sre5W0{{SjbdX5$I=t}szilCz2J(Ifj zttVk&X!p6u_OA1vcQj2-Hg@95*H?9oCeVyj$&fUZ+j<5XKt!pQsF@?wquUEu;&@s; zB}k~R7da;$UR3g?I;{DQ9bi=8eWHN;g*?3nUhKMRUdqSr3SHY@pW7>j!`3}k@LHN| zj?0rFip=4s#Z=&MJBBqmDq5^1Qd8AV)RI$0RAmt}PJ!*d3jMkk1J%zpT9e>q1x(cn?yz$hv^fG2P^+xxK2gWOJM6SneTxRpa zQpa6Sk=u~t@&5qiRMd2d<^85;Eq?{^x>NP z`WAZMwq@Fzw_sJ8|%DA^;>0r+XczdxQjv!Xn0AVAGhNjW6>WPd*S}Xv8pjJXKC`Z zFC=x5WNYwQ%$!o~dJJ_&hMKQ5wJfl7{J(7vuRz}I`IoZ#ux$K3=c3GQ2Hn*)SXi^W;tDKIV|;E1=_G?0T|-HT zT#P3lJvolG>z$)mXyt^6v@z1ew=l*DoakKt083A)G_D5|{=wWe0bitKJ&%)Mr7~2Utr;5sl@F`F!VLijD`#NH6)Z-x{SJFrVvv_xUz}& zG_`b6idvY6bkN~}rM1L2ka&>3r6#pG#{u#+<6oaz+^yxXc%o?ZniF3t`U?540uD#) z=uh8UPP-{xvna8UVrcgM2MxM0)RmP&j(nyQHI~A|g2Fc5k0T>ZCP-r5rZtf@PkWMY zYrjCu8ORGl2a(T^^v9=3t)^JgV$9wtDtzd{O-`ae4QXDg54gK7j|1|nt9CBnsGgH2 z)cBkoPhaA<&PNqTw)Zv@bDV+AY}}@JyxB-A_Lk?RgCzC!?+lfglHkW6(tVdpa~wAg z>?*1$QbiPyNY&^UB=KX4d31XOek?3Q1(HKv+V^k-sNha$0Kh$E*gfC0H;xBzZXL-r zZrI&8oteBM=^Sn=HT}%lb@Z4D`S%t_FD`c_G?{8Fo;GTROlV=?#%e>+&CT0voTvnZnNx)n!VXwl*GMN4JJEr?m9Plg)KD|0Z6wN8zD_vGPy+k z*=Cj1A}BqW#Vp3%nr8S2Qfcs$)E2wsw6h)#lL* zHPKX~EMP{1UsLq(v7L`-YVh*%955IB2c+{_J=BiYqfD%zY3`+3idwm$sHHs!BcqL; z`3=(BzpA%h?Z#&IzV6$5Z)f6n&ho`%D?ZXJCcwzo)ML(Cy$lS|vYJU&7?B8@#OjS} z6Scm#@Quij2+>25L7-AFcxJwt=)HE!`+K+D5^7?Vv{RLRlK&oK4}Gh;)7&vOb$+u6OYH!5lu&olO^On zsNG(%nJyOG4h>fwq+>au&m5Z50-Y_pSsQrXD#@wg#>HOYVy1v9!)Y4tJaUyl zZwRN!O>5>(DAn>GXG}fCvub|~l4(jg#wbRS@eVq^etju=A8uuHn})w-@64`8d0`h3 zMNfyX&tfr^+e?2a-QB#X>EfW)YH6!9A#gx6H5jHkBoJWt2W)OlnybBcjut)t0FX|*+;5S@;^fKT z^LT?E@yX;cQFz*1f7)Pn`K5tt7D*}Mt`o5yLGMu@V2vS_Nv&15lY?A*KVdi=IP`OK zB3mTJI3ghiuGTcuT{J(%QgT-Wig|Q$JEwnC?TxRzDK?fOkw!0W=XZWgN4#(Tm%F#zX!|7C2B$I)|0RI4#^i5%IC!W__hq=N9 z8DbCcjx-)$v!N5NyEkp`eYvx;I5{#?CgZ@<iwt}FFiW(jV^Wr^6r%c`1&ucBhNE8)n9^icZsmJCA1B1}1 z6!)+M5G-4gHEQPK_iKUe!>AKqOmvI^Uzb_@!+=|xk*wSTZS@KTi68GH-m5?e>Y!)k z*C!#7V?Kael8t63^#TouD}VO(Ap6+kUoZ7=>fU6L<bh`p`aDr$c(Utjimf4V6U1Fg7$kT`Lv z^RK2s$4M!AVuKe)m8-yRs*GiAbwwO=SI+d+DMdviK^+YqGZ+?%lM@^iuGg#LU-i4e7Ce zx^$Ho%x>h`xm4U(ec`=ksINv};xOf?Ad4cAMypIph8m|0?y@k{qM880Ks~>K7$?+u zeCg-w(x_!a8FVB>ic15;Qj}kq&Og}bTHRHf6JqvH@tm(1Ew8Y0F)m`hrlT!eI#lPW zam|g6fJHmQkkRBZLrWXf2$JDSDL(D4ge@sRNk8#if6LPdB$5HA`$m6+547O=dXtRw zPq+6^&A4bJrx~r(nGMs8hd8FBR{EFLJwRVSJtW|kLK zNgGv~DGQR!q||37qq-)f>lo;ifT3MC~LGhm4jV zWd&)&?HxX`t04{wwMQDBK11`#AXD?`s&{wyuYYgNv)Og~zZ0=D8O^7K!|ndT-tAYo zvOA+~?dr<>uHM=kryCrTeU9d(K@|mL)d;yL;8L?bRleO<#dH$TT4F1o8e4u=Qq@ z^*vW1az_D?Tz#g2hJA5ff;^{F)bCiScTOv;ppX0ciQFafYrkZveY^Y8ZZ@IKyW6M4Z?(0ofqHCjJl%) z6!;ipNG6!`rg-|+y%gQ)v^KRz$^NRwW3iOjEGJ^t;rAwfGr2Ka@{X)_Ce+DKk=?Q9 zDm=L8e~w{tQB|}R1*&nisOd7S$!}`>P~}=S701F!{8+CZ;lYQf`#LKrN#g-X1#9V3 z>-&7hIzB%Pw{Fnt{p;FW*Ssg9?M=h+`YJDveNES*n^s49&y1q1!r&-&_D>M;d8$8$ z%N!LMDxFkxR8h+%M6ufKxYNP7k}njI9zemtB=G$B`4iHmX7J9R#eq!yroMyzC{*;a zkF2RN`L5{RJBJUnsy7G6?T^;j%I(dO!Byw6*nZfr-MGHUs@fe#*SS+igr5zuy2~R= zi^$~il04PX%OzW}c_LKQPQrJMM`FZDBZw!tP!obyhQA|ToRLG;Kxj+j)YApNLZ<)< z;+++bmsL2b&c@mM5`%y4?fFHD%~sOqGYR|}vZr;&mB{0F4&680Wsi4mfzvlRDJ{YhA$JZMW))4GO?DUK8r{9S2L60X%ExL-3`*FP>b^y$iMJytJt z^oLmOjHM<9k~|*a-?+LAMO=9Vm$-krOSYw>HG4$RJlK8jTacos$wwTLPmTL&)@qeV zrS_2(B#7l5Gw2&97mU#^2QGUX^w~QjavgRmh5@EGqkt9;@Fem zav4p5w&-?VR|$dfHs6~dxgWINbU$kz)7Y4-&JL=brh;XXlA0K!lEk*EEwqtK`lnQC zK%gfEgVgX`RC(m|_NZk-_|`$GJP7mr{W_Q3-RspKBr=pcQylxV571p}UYqN7?*+%j zxc66SW_I?PTz)fcWp|ca_%2^*3WAc?RYWIz)lo>GrYMfba@-?<4w8`hoMd@tj!)P~ z#(MIt0DUz)Jpn%<^70?gqWRc;GnCErw`6Z^*DTu(Ew^2?^3@n!?VQ3__HJ^0?VFmr zV+t*)j5AG$uE)?#4A`805(p-$s!;W@!%Uv1bjGpH^9l_Gsl+g?6-fRb6*x8dbb|IZ zmL`i?2N=mU9)IDW;5rBXKJ`Y)>^{@%It`_}a+poO*7+@~kn60T?%Vm=t=YJDe3@K* zEp{J0SU%?+K7Supi&bAsJQGz`DV3?!Q&R$XU|B*#DzX_8tbc`v1O#v-8mS);9Ti$e zlij*5qXZ_ZDUy82udfW?bSh`F_^!5ng(kOSj(wnjA`$BHH&tYhHPg&IMZO4<$ z$wmA_JF16eW%GFYb*PFr1)`#2PM2^B$xx$A{ywSvMn|p*2ZIkGT+`3Y^kFsStVSe; zG+7C$ImHLUPoI@(_H~;t^2fTi2H%!C4d0U3`6)9Go$U6;;}>{sj7Z%Zac9TxF1p;= zI*j#2cG=xH>FGa-;bW{g>GN*&u|*V6tX|Pgbu4WoBUG?phGc>G0FhvjiC#FAUyGof_^3Nyw-f5uj%kPqe3 zM|+}?H0q;JEDs8bc`vU`*en)Pd2KuvD{gHQPrk->uOo~wR ztrUVh+P+4-E5naV47CQqquKFoU9*GR8(z0;4EE=0{g1ktHjOO1nWJ5~MVg+VL0^^J z({1*RV4$L_tXecaHf1H#*on9&4<3z^cd(Cb zHgMUMD#nEg0I}jKGHM9^;t`RqBVTs@hD@H|XVuXqc43Woy<6epE?`@)m z9k$5jxQ=aFYC{zV&Y0umYtqLrvN1dOZ_dT1>X|zK0CHfn*j%j#RN%9DeWQt}!dL$Q z5R$h!o2bfHt77W$>l9JaK_raT4vmd2Q@4>5NK$l%gViCi}>DlyK-jxD;L;%t3Q<6^>XB7 z+gYp*+S*vv!9^A#f~tJZ1IdueQDCS%QC758X=+%j%1!U5lv^!?78mx=*h4C)4y^L9 zH9i6>iyM&&F!3C z_}vh0X}Y@uv+?^&4}i~#E$z8*G}JjcHcVC3llUlhdsV_`o;_0&pQFsu{Ccl#HP zYl}Gkj85*-mQZ4e$){+`JvdUjjR(u6kGE_)jhBUIaW~eK2(hY?B#}p%;1v1#4wyS9 zKf1Tpb8b`8V75j>H`kcDZ1!feW>e$j+xub_pA`;nulUB_t8eTcGgew4bI*QQH0s-iX^J}_kt?kRJv;b9s8Y*yWPAavh z3Z6Y6w?uhqX|eM&R#M;-Jy}7vHq|WxYk*rlN zLg@Ck&gvN{9F1G1i&kg?R)8#r_=?ww^*9}9$0f9GvOuEAO%;>zkZR3Ck)M#qO*w3r zVdgO@xAzSdJ`OzWDMOc_#$(cL)l-F>iln06dDbywXg3XXa7E#vq6<61#yQyN3GS$y zO|8o{g13no1sJiRVkq7koaq^z3M%(d@YKL@^Pu23^k{xF z^^V`|y|X7*?*85D9=z+Wyxmy*_AKYHuaw-?QfKFm9Gsh9ZNWoDL%a->n5RnEt4vD^ z>3JC;jz(i7nhAAHcQ}A3*OvV<#DuFi7Ag$@2AQX?NYRJ}(oI5IfUBmgK+Q#d6JImr z3F*JJ_Xg?P-^I38+LpI(?CL$^yJ_Xz8*#S1FJ0yGdzjKiNt&AtDx7w znGN@zgB~!Lr`!|Zw*DTgC%AX5bwfpr!{>38dwfeov~p{TD5xMSEOF}bt=X8ioXI8f zOZ&FfpS-5FWkrovq^oMkBaLaFUY0j3+*!#V)W}iT)GDcP%1$ydljrOEI!aMyHtsp9 zai8eVExBdJ%U6ZN=eK#Oz~MIj;H{3K_bz8EK*Evh>ZnaZv~d-aMCgklvC>@nYx|at zWZl*^a2?V<_jLqlRbx;CP=abONg2;nm7&{&@!naXloAn~X;p8;NCvGzOjD0dINHpD zEq5N*DF}0ORxOe&E4mc5Ou4Zfcrc(L+a-4Gh=} zbiNjnuN4h!f>@b8_Ks|II~93>aZRhnS!~Pg>1}Ofk(ne@a8*TpDr-u7&T0F4&=Kg7 zNd*bY)F0>PUOz7`k{GjO5>PlbRnTBFA4>M*vbZyK0kF z&jfp7dwDKQusev-gck=WDNSF)U*-EcOCG107CU;5BDp^yPoK+=MwchE>DHTV?_;;6 zuinjo$!~?&vf=0=!{FkUDxAx08cbz0?Uky@S7PXZcB#`*9CCQlJw#k51^vSd*cwG$ zpgP1S4%*!6Sj)2!q)@grjWx+M&qAqowvG#BRd(LqHg=4v=~4k;p?H!&$T=0IE7$+m z);Dp};qtiMqdwfBsKjOR6tUv7`G&~^q^k`#@cPPXdN8xej-e(m-*S8n&Aejw8*AGE#`B9$Dt&P1m@9f)I}*2k?Lr~;Eg^@jGhP6p-Zhc#1xsm<%q$Y z?k0js8f~vpj)8L+jo+KbW1`1qA)9b%G_=r71zl8C6!9!lAdy-&WJX_VW|=;kMrjzM zr3a@PfJgCgp{{99KAI}{%K9yiraH3zhxu;!{j<6QJ|b{}bVZoZ8xr)op#XG}y|_eQcYrWNv7xu>0>JUzMVyq=rbACZWY3MC6g}%UjU`M6Au@ z_-Kb#>iEj*SU*|GU;xOk0rRQJC(eT%N_uqde)!wltF(9CPaQ+M zvRietxgD>%>UPG@nk}V+mp_KZ;d0pgo$y8BeaFrQ%N5xG#0uPV`Hni}hH#d48 z#$7;EQ&*)kL*{G6fTvcM^cf=|d_E_*p88Ul7}Q7va2y9g7s(FCn|pQTA))KW*qM#N zmBwW=Tf-rqY^=0UZOR;$++-AsxTIk!#gL&wrHV$vliVV_GI)KV zwH5UZLr?Ueim&IMlFca*KC+-t6GO+ys_-Cj)&5?Im&gpxTE82C?U->`O`WuR!izVF z>fX1+Wa_1@>;AOsnyL&0M@&RUDKps=niVog-hC3ejYBWJv{aOrAF9-I@~Jc*v-WhJ zX-iZBDE-6%Km~h8^808$eIS#qx5ra#-lXpw<|NbLduC0|l@?x3@wr>f@-^RK5@ z3AOvHuJT_By8|=YnOtt>@7>9qs#u;qhl$_2GjQ%L-b9Q9r=+SddT>YaTv!~Q5>RNXXGv{9_SIf(#hRn`S zv@@IPuAgvY-luKh=i87}<)fa043zlXoHf-r8VF<)P508z;|^q1e-fTaE$ur(9W{o~ z<--LT)l6oXrnBEomfb-N!qlfC@bW0e}~T;an;mQzN`Qs z=gaf*sL%K_&{?~-W;=FZGkt@E+d0YhE?&O8VX_#U)-sBUhMRBD&t2fp;bW4A7md%< z2p+B)IOO|^+GymOB3?#MY|Rr#8o8rt&2dUroH*12pCRSd7_5QaVyctt>GL!OoN4)V z8{#%SeivtK{l)%}C~}>Z-jU<-+kb4}YiF;>nCdrT?ajS`-|nV@uK}5@j)ofgN;zbj zF$_`5<3}OA-bkZqqk<`F^j1H@D^f*$M-#@C#X6%ehNz*ArloOOA5Tx&!=e8G6~DVP zXk~Y0PIGg|wzGTl1A>De7Tnvp4d+`|w`ucv+Dg172IK}ov$6PGjWuppDJhu@ zG?8ZU-NgPI&UC*1Ziq9Z8L>U6q91L$C6c z`JM0C+g=3RamXk!+n05~xpdw+37(ERyr$a4V{0msmC=Hvlx#vOs)lv}Z&-C6!w~@a z0H&wp3jz68*R73XLb23p)W-_(r}_F7I)aPipI&bM+})e!YwaAu*Y14Y6JTte$+hY? zt$pZmaP7)@I^MD+o{_Ve8jPG7DQau!DdMGo!{Rc^(lk}HD2psnsV>Ce46RAve1QB+ ze8;C%0^h`Tl^4Z6l&=a?^8WxY%cOo^3E3I!E)Q#U=IYOE&bREH=~LIZ{-?#~@il#e zU!FA7c+Jz6+&ga&^5b_s4qBEt>Y%2eQq(?2ous=EX)0VzGz$R?kEw`Ou9XI*Yl@nT zu)@}zDO8I_TTM==whkKVnNX`Q+VLXwx|? zPX#;=jlu1>v72VPmWIBbSt|^X$xew2NZ>bVXD5oxZx7R!Elo8fCv$3VUKG>BaXl!G zM58x`RM3KJUIUFjd^)xrgYi=hzdK{HGPK*HX=nEyD+ZWd!$Gidc^$JNcA%z-wuOFP zi*waaTNQ2}2LzSr3RD!1V;s=RN;>;8U#DoNmNv^1LqG*<-I2!x3i)TRGX*-BAJ31j zX@iagbg|f5f3SWV(x+&44$zuB9Yn()yKo!BZDFeGX3pea_%_m`#BS_Po ze%7&}2O#o15(pHD6{cuy)tP1ruE24{PnAasQj{Eclh&~`ej3URGf;kherKl+#@%}( zq(no~U74d;J;iXZFAD9&b=4t8Gkg_7F_-Mwwdqq6_ySRFvmsWv->+QEs zn`(^KT4z_1hZBUr;aZm!vmm6PqJ=Sd$+5IaK2sqk7RT)dQl?2@&>Sl9?`-&}T9Qd5 z@&5oY{JzeTG>=VD41tn(*Ub3=UpiyUqTk(B)E-Q?UTpoJw09LIXKTs^13$Rn!|%8< zc^$z~L0^*DRCB{J=hB`>FD*k$0%Ph@COW>NfHaH|m<2vwU~nhoKc7k>1cffdpOt7u zI3G{)^$V(gFLhT>OPSgmtF7yIjyoYuMOA~zZi>2H-C3HW{tpE%cAxI|6bl!4*`3kA`dQw5l@1paM;P&r(n9>dia4hK1BqA7`iJe7$`73^tF&U9VByU4e+j z;`b$87ffyruHC(Tmc`@#?-=8B?2|QK)_vbAmCMiyHCd}^YnoZ;96=TIO{{u41@UAZ z`q^uMFa}4T$o%Qn%;8lVP+F6MX-Z=Q?cq zK3T6uds9Np^UotJXUjdL8j8~gIUs-!%a2vlf$wdx)b*8FFOPXn)!8(decLY1+FOeo zh0D=V=JRxUJOp(V`y3$z4rjnp8 zk*ER~R-MBj5mISR9SW`Did$*Fc^H}^NGkcw4|3EJDZ^ELYH&I^y@T`PDZV=6t+%fB z#OL;8U2D8EIPJr<_Ovm!ce-ct*7Nu5(fG-|2sOc@1#cr1q$748SGE{3AiD~c2k zJr`e|z1z0?T0ABft#F-hm&tCNE<>_+oH%$WmvZf`#X}7)?8Ij4v-I&SS!~rTRP#|} zB}Jzds9p#Js^-?*_XN8VT3f&9v|*b_0YN9>)#lmq&N^dwK;)^%TS^!HtE zXfYfA0R1Oz%!Ku|Yq>CnOg(bZLp+F-Sozsq0?4M33tLtx6rM0rNg1sLa6l(hS0EEY zF~_8hEVoSrj|#>O3X&YdWNM?U>8NWrZ(Q|;2Y%7j&|tAO zE84g&!R*YALP3tfU^96fMHcIicm+qHp`@vnI!Ili5VVX*T!7q|Wk69fs)w<)A1v{& znZ-I}hKI+EKw@;0!GY8Gsxgl;c>Y}*KaW_fcWQ6!R@~e%Z2ArBxcbh1#_YN+qm;+) z-1Qe`ZTkFwU&U2Xk*LSRjLT!UUct#@YUHSz3Tm2qoOHBv!mk{fmgq;4Rd5Lk3C9f6 ziLdSAe$J5_r-4|ZG#I5#GoO*csQl_NPK{>o$L~&++IcOxyK!4nfAxM-5f0U<+_CM7 zI$WMc43z~`18#OSlyO1)Gj8H!3YdzxBl~5osxZqOY2tH9Lb|MDQz6DcG!*<^tL5_h zI;;}MB&KmIEQ)FuQLYY3@db$Zyy={tif6$7tEb0y7EigeX1j5=z};Kd4@wu9sdjuDbVgV@5`bY-Z+?d@-WlaEhF8tI2I#>+RK+ z3ULB#Bu-UEEoO;}>I8Dt}o5FThZ!Bus zJ>vMhwH87;O#bk!sc}6F6!goHhzDszv5S-1zUOIk0g4bEjS49sGdIFmWb0$(Xb8#A zLVLJ&yW4r8nS(~S3`Ghm@l_y-stkf_q~e`f_sZ__+cn!mvteH0bqE&gBfg-8h*D}80s&wK z6fim94F-A#ZMIDmn}m_TrWIvCO?#Bkk{Hk$sRWD?ICM+;Q|CtU>u$=WkGC-~)nGPG zI;#VgtH4KttKa*wEy}o@ylsZ4r>RU<9-@x}EfqFSdS??$139?RN3=2SHpj6m z)s&d6!->mPY<;jXyGJot)ik@4a_#-svGDncHeMcvo=om;mmz}AZk*;aqAD7EWHUmJ zX_v_$kSP$xv09BG3b6~2Dj0A8>L<9G{5SxPi7s_t5*~~JWu;YFT#Z!}2CATfbr4T+ z$5!u&-`jIBov7Q?ITKZyiEC=5#?w>I#}!3Pw9dybQR7%CDkXZQSkBOlU@Ufr8xO?u=}0?B-oMbTO0oXsvnCV>+fVzgU_VY zB=PGn-2E(g=GPW$a%=(T>;B%}^aV%R`Sr8Ju=DB#PF-CLCYq*QBUgwPiK;BbtZ330 z^{bP2xwXGn9{dz>JbJi8P(c86naO4MEin?|cEnYP&X84#+30D0+5p$8ohtNIK~)k$ z!pc-$+E?qvf|dXmr&kFbDDu$p^Zx(`M8o6}DdZD99YIINaGZ}-Lz83IMW_02A?8g_4!Q4MzU)1lSxdM!UgurcQw!2qe{oZq`BXL9>{Z*Q2ZYH;}4*2~ahqp7Q=U-XE(X@HAo zV<}>jO=OwNg{Zfj09>KARg>el~YO@@E?d|9z7{C=qFMV+z;8D*ZzL| zs1Hb*j7I0(lsl7nVYi+-eY29>6_mBPED?RnMt4c0HQOeo@tI#<+T3*%IA(bAQ3&3h zv_|(W81`Qbt8nhoNWoRKzYtC{k3ifHmMM;(mMto}^syqVKqv>5NaIj*$ISHku)8{{ zj;dYfz4uHzhj>xbZG1N9qZtV+v0bCpc&w9dnQC!VHH(K7y}i=yB=u3iAW5rfno4#O z2@w@a1u$r+?O~*~PD@nALwS-azKu$8JttWNGc1IYAq)*M+QOA5KjrChjLmh$R>bSx z#l+|8cbzp)Vs3qvy)xSqB}2Kp0*_}<g&O=>BpBc$@k00aufPn`kz;{*H$Jr-PkTdH$A(|+#S zZ1&B{K~GQ zf794Lz`@q;9ksk*+x-{3)@)5C7i!`6%|(2eO~<*n1hLML4+e5atA?sMrG~9t8Zo3V zyGUb>RxmXo4XVFs901OxJWXqg@I4mOxe?1(f{rvEpJDVIdNQ32J$J^A-0jSM@}i~O zlv_6;nc3Z$nT>`AGmG0DX4nqE+?9Jqo~f#Hl{K564OGw=XcHSNA5<86LvO=5QT>+ZT4J=ci-_~XrMQsFno;f6)2sK`<8 zN35QvT52gJsjc(0)0J?r2O|wImBJcu1cF01nW(`!tLf>-t;Q7uW$IRyAY%v0o?JiG zTJ$#Ha9s7HwId}9lknewDB~pU%$5A%-Y*lrxUrUw#5usEC%fCF51In z>#@Cev9i_CBstu?hM#cOnVOCm+BWjCQ*uqZ!0jQGR48Pj?NLC?MrgPs9MYj`3F$nV zk}55^S%J+nQ^ZvN0DyjHs{Fvk*IRF6zUAybuiKlawKF}tv-c-f?7H3cUq_7C)NyAi zr|i6cXYI^%6>lX!VPdJ)Di_J>$Op91sw!zEmiO#U-ML0|2&Y&EhCtQU37{&=+CarI z(R_+6C*2SsB$f#t*Aytj**o z@sBlHPgh$+)5RdiEQtiLq-fealP|XJ4V~NFT*!+U@dyD){?MF&JgHh7nt}4^33i7- zS1l(Ro)kF#f92{;PU6Jw+O3(N-I&a^G}-l{p?WBVQLB=W#iMd%)|zP`{s*^k@Suhm z=weu6oVK|M1p7}S%VI8V?NwPqYp#&AYWAq5bur>g9+bx+m%_P~fN=T(jMaY=ALx#a z9v5@v*RZjBV|~rJHva%w_7>ON8&9h@1uiaat3$Q0`%4`rI=3;kF>tLV9%BcVu4w49 zIOh}p02P8+i%aIv!3>s7X=`hEvA8PwpH~(xb5sB+<-1BqcgsBaea8@b3>7XL?H4cSd<_H2 zDY4k<5R=PKB#6dVR$*Cfw)=~#$F$rGe^DC31Y~JVAdtY3KuD_{SX%j4rBJrzZ*OPg zS;p}|?I>r3)Lh3sALRb94v+iBJ9&7qRP$GP&GcB64NKM#(`Z7gj)ad~rf zliw}5sw%0PzMcapnr%%OR8OLLmL~A#+%6JJly*SHekG8RN^lFOwBv}c9+XG8UrYpB zeY(SFQEH~268<3EYhE8Nl3NdBZVm6g>*ih8KV9Xbr>}yecV?<`G0^RdZ3z-jO*J+y zD(cENLwKjiiY|W!jklI^sGrVzdQf7A+K`9lZtifmKX(p(x$Hjwt(TdpR zNZFED%CwrjY(jLe9dV>TD_wct})P?mu&8CmEyXdHL{FoSAGTQ zT`G9ioKL1YGH~Mi7dcT!vog5Ny^QOORRU)yX5ExKXkoBaIR=i2&hp0nolZ{!vK6vS zWO}%&DgN>}ry&>spn12Qy7nnVCB^>$idsUo1xoQ{DgbE|02nK3(}E9DKHISFTV3=r z?k+!dv@}2%5^KRq6Hu+16NARR7VV$6`g^W>7x*6M-Bne+ch$L#@wqzEU9AQqIfu;2 zkfK@Z_P#$oD^lmO8@Df&iDJc7RZ|>v@uI>&jesH+edm0H!5Gsm)M&Gkuc;_SBnyP_ z%H)+`%1wOFT3Gfin&qco?Cs<)A9Cp>fvWK=7#sn>f%51Hqu+ZoCr=hgCLDd z8$~YcZzX$;lDBUeMN=CE5!WEGVhGdr5%_X?Yv}f?TWdD3zi$lVDj=quO|UdCIL$DR(^%A3aI7sG$2@uGD#K?M!tvyRM>XTPH_@j~z!R+euRxs>n0> z(m3N-?vr*iUIdES7sEopxjLrpQB5a-V#F4wX!v@r(Qy^M+udCk4S^hB&}8MG3veKK^aBiQBbV)xcmfQp?yha79nG=8q1>rc4zE3p2NtY^EYC49y%ZQZ-IRn6j5P zyf*us8-2^%q$2$gcmyuKG!i_B)Djk^D~$ZQGLrt@-hT=MaV$*5q>Qxz{s9|tRa(~v zpk20R)})hkV`(#WmH22PdaP9=?pp1|lC8%=4Nu+VW{PPf##L0-qZCmvmQ6b6uwQ1@ z)9!Edt@Pl|`a=x?7QZeP3cO7yI1Y&Q?bjvJ=DL9-H<%y716HT}nXg$7Ug6-R+mDmp zyM~^(G$`^Wwx=gexbfJETFp_;Xz_U(Z#`aNK#htUt16Rw{S6 zk-!J8ntGGNszda%h^^C5gx5)}5h=v5;(o!>hYyIu?Z&8veCBGQYN{x))O(tArl87~ zcZxc+rp9B#{oFaJr1DEl-jFsUnx;8gr;hwi=Pma&+jEq={^rg$?@vBYzOA|w_ z=0<}HRT9JDZ!=9Pc4CpG{h->O?Y3RI#_MO0Ey!e$GXlyDbealfg;<HaJY(H>zmybJCd_<{nkdW0h8QRISMJ^ zt^O3mO^Kzanjs-T4Lq@+j{c_hB>l;5Z4TOGCv6eK}#~R5Y7-Q$0w-NdIbZZ5~k}!qev=1Rp91rE^(WvY0(A!&UsXKQ8wkvay zZY}GN+W38^wkm7!@IFg-<})x*jpIR=l7gEd1s2oD>YAR0M`hJBGZFw|z8LM^*)0mG zazPap!sL9b9O?ByRe_aGtm9~+qSDeuC82#IEIR&SL{b- z4p>%(Taq+)C^b16kCg>*2ch-y>8s0K0Vb9{#&hO$fGLl(`TF!)GZ_K8_ia+rWU6WK zxn0R6E-HP&UacM<6JL-JQq)vD%MBF@tVJfADn}l>lWtqu_+tda8N(V6+w(u==^GOy zh1h^kpQlzk^EavU-8O(g3}=BSla6B%RcxQblHC6%-?AB2h^4Mq#|U!7}?mAF{sjn*+*(uY5ntvvoj ztMYpIe?)NlxDfNBQ<1{48G0g>i<(N7VCti@O@DhQ|^f}h~y&^=4I z_l}C%jNWPyiOFK}TMDl~TG)vxDsle+#7xPV!!1m7xf;#MEl-p8ftouI)&(

PYi7x2F}1WgSSnVM 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�Pz@TqbCFIt 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

Y{vED4#E>1qL_scl%O^#Ytv#B+c; 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 z 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

d3~P6&X15>(GfEl`N-i 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 LwZasAZ1Dd9duYFK literal 0 HcmV?d00001 diff --git a/paddler/src/agent/continuous_batch_arbiter.rs b/paddler/src/agent/continuous_batch_arbiter.rs index 979d41f4..b010488e 100644 --- a/paddler/src/agent/continuous_batch_arbiter.rs +++ b/paddler/src/agent/continuous_batch_arbiter.rs @@ -112,9 +112,16 @@ impl ContinuousBatchArbiter { )] let n_seq_max = desired_slots_total as u32; + #[expect( + clippy::cast_possible_truncation, + reason = "n_batch fits in u32 for llama.cpp FFI; usize is the internal type" + )] + let inference_parameters_n_batch_u32 = inference_parameters.n_batch as u32; + let context_params = LlamaContextParams::default() .with_embeddings(inference_parameters.enable_embeddings) .with_n_ctx(NonZeroU32::new(inference_parameters.context_size)) + .with_n_batch(inference_parameters_n_batch_u32) .with_flash_attention_policy(LLAMA_FLASH_ATTN_TYPE_AUTO) .with_n_seq_max(n_seq_max) .with_n_threads(n_threads) diff --git a/paddler/src/agent/continuous_batch_embedding_processor.rs b/paddler/src/agent/continuous_batch_embedding_processor.rs index c6fe1402..a75a461b 100644 --- a/paddler/src/agent/continuous_batch_embedding_processor.rs +++ b/paddler/src/agent/continuous_batch_embedding_processor.rs @@ -82,15 +82,15 @@ impl<'context> ContinuousBatchEmbeddingProcessor<'context> { .collect::, _>>() .context("failed to tokenize embedding input batch")?; - let batch_n_tokens = self.scheduler_context.inference_parameters.batch_n_tokens; + let n_batch = self.scheduler_context.inference_parameters.n_batch; let max_sequences_per_batch = self.scheduler_context.desired_slots_total; let token_counts: Vec = tokens_lines_list .iter() .map(|input| input.tokens.len()) .collect(); let planned_batches = - plan_embedding_batches(&token_counts, batch_n_tokens, max_sequences_per_batch); - let mut batch = LlamaBatch::new(batch_n_tokens, max_sequences_per_batch)?; + plan_embedding_batches(&token_counts, n_batch, max_sequences_per_batch); + let mut batch = LlamaBatch::new(n_batch, max_sequences_per_batch)?; #[expect( clippy::cast_possible_truncation, diff --git a/paddler/src/agent/continuous_batch_scheduler/assemble_batch_phase.rs b/paddler/src/agent/continuous_batch_scheduler/assemble_batch_phase.rs index 0e4be3ad..3255d18e 100644 --- a/paddler/src/agent/continuous_batch_scheduler/assemble_batch_phase.rs +++ b/paddler/src/agent/continuous_batch_scheduler/assemble_batch_phase.rs @@ -8,7 +8,7 @@ use crate::agent::continuous_batch_scheduler::generating_contribution::Generatin use crate::agent::continuous_batch_scheduler::ingesting_contribution::IngestingContribution; pub struct AssembleBatchPhase { - pub batch_n_tokens: usize, + pub n_batch: usize, } impl AssembleBatchPhase { @@ -41,7 +41,7 @@ impl AssembleBatchPhase { continue; }; - if tokens_added >= self.batch_n_tokens { + if tokens_added >= self.n_batch { break; } @@ -83,7 +83,7 @@ impl AssembleBatchPhase { let remaining = request.remaining_prompt_tokens(); let chunk_size = compute_ingesting_chunk_size( remaining.len(), - self.batch_n_tokens, + self.n_batch, pass.contributions.current_batch_token_count, ); @@ -124,10 +124,10 @@ impl AssembleBatchPhase { fn compute_ingesting_chunk_size( remaining_prompt_len: usize, - batch_n_tokens: usize, + n_batch: usize, current_batch_token_count: usize, ) -> usize { - let available_space = batch_n_tokens.saturating_sub(current_batch_token_count); + let available_space = n_batch.saturating_sub(current_batch_token_count); remaining_prompt_len.min(available_space) } diff --git a/paddler/src/agent/continuous_batch_scheduler/batch_pass.rs b/paddler/src/agent/continuous_batch_scheduler/batch_pass.rs index 84ad08e1..e5bfd0cb 100644 --- a/paddler/src/agent/continuous_batch_scheduler/batch_pass.rs +++ b/paddler/src/agent/continuous_batch_scheduler/batch_pass.rs @@ -11,9 +11,9 @@ pub struct BatchPass<'tokens> { impl BatchPass<'_> { /// # Errors /// Forwards [`LlamaBatch::new`] failures verbatim. - pub fn new(batch_n_tokens: usize, max_sequences: i32) -> Result { + pub fn new(n_batch: usize, max_sequences: i32) -> Result { Ok(Self { - batch: LlamaBatch::new(batch_n_tokens, max_sequences)?, + batch: LlamaBatch::new(n_batch, max_sequences)?, contributions: Contributions::default(), }) } diff --git a/paddler/src/agent/continuous_batch_scheduler/mod.rs b/paddler/src/agent/continuous_batch_scheduler/mod.rs index 5006936f..ae8b27ce 100644 --- a/paddler/src/agent/continuous_batch_scheduler/mod.rs +++ b/paddler/src/agent/continuous_batch_scheduler/mod.rs @@ -29,9 +29,11 @@ use anyhow::Context as _; use anyhow::Result; use anyhow::anyhow; use llama_cpp_bindings::context::LlamaContext; +use llama_cpp_bindings::error::EvalMultimodalChunksError; use llama_cpp_bindings::model::AddBos; use llama_cpp_bindings::mtmd::MtmdBitmap; use llama_cpp_bindings::mtmd::MtmdContext; +use llama_cpp_bindings::mtmd::MtmdEvalError; use llama_cpp_bindings::mtmd::MtmdInputText; use llama_cpp_bindings::sampling::LlamaSampler; use log::debug; @@ -41,6 +43,7 @@ use log::warn; use paddler_types::embedding_result::EmbeddingResult; use paddler_types::generated_token_result::GeneratedTokenResult; use paddler_types::generation_summary::GenerationSummary; +use paddler_types::oversized_image_details::OversizedImageDetails; use paddler_types::request_params::ContinueFromRawPromptParams; use paddler_types::request_params::continue_from_conversation_history_params::tool::Tool; use paddler_types::request_params::continue_from_conversation_history_params::tool::tool_params::function_call::parameters_schema::validated_parameters_schema::ValidatedParametersSchema; @@ -709,7 +712,7 @@ impl ContinuousBatchScheduler { } }; - let batch_size = self.scheduler_context.inference_parameters.batch_n_tokens; + let batch_size = self.scheduler_context.inference_parameters.n_batch; #[expect( clippy::cast_sign_loss, @@ -734,19 +737,47 @@ impl ContinuousBatchScheduler { clippy::cast_possible_wrap, reason = "batch_size fits in i32 for llama.cpp FFI" )] - let tokens_ingested = match token_classifier - .eval_multimodal_chunks( - &input_chunks, - multimodal_context, - &self.llama_context, - 0, - sequence_id, - batch_size as i32, - true, - ) - .map_err(|err| anyhow!("Failed to evaluate multimodal chunks: {err}")) - { + let eval_outcome = token_classifier.eval_multimodal_chunks( + &input_chunks, + multimodal_context, + &self.llama_context, + 0, + sequence_id, + batch_size as i32, + true, + ); + + let tokens_ingested = match eval_outcome { Ok(tokens_ingested) => tokens_ingested, + Err(EvalMultimodalChunksError::EvalFailed( + MtmdEvalError::ImageChunkExceedsBatchSize(mismatch), + )) => { + warn!( + "{:?}: refused multimodal request: image chunk has {} tokens but n_batch is {}", + self.scheduler_context.agent_name, + mismatch.image_tokens, + mismatch.n_batch, + ); + + self.sequence_id_pool.release(sequence_id); + + if generated_tokens_tx + .send(GeneratedTokenResult::ImageExceedsBatchSize( + OversizedImageDetails { + image_tokens: mismatch.image_tokens, + n_batch: mismatch.n_batch, + }, + )) + .is_err() + { + warn!( + "{:?}: failed to send result to client (receiver dropped)", + self.scheduler_context.agent_name + ); + } + + return Ok(()); + } Err(err) => { let message = format!( "{:?}: failed to ingest multimodal prompt: {err}", @@ -938,8 +969,8 @@ impl ContinuousBatchScheduler { fn execute_one_iteration(&mut self) -> Result<()> { self.advance_generating_requests(); - let batch_n_tokens = self.scheduler_context.inference_parameters.batch_n_tokens; - let assemble_phase = AssembleBatchPhase { batch_n_tokens }; + let n_batch = self.scheduler_context.inference_parameters.n_batch; + let assemble_phase = AssembleBatchPhase { n_batch }; loop { let max_sequences = self.active_requests.len(); @@ -949,7 +980,7 @@ impl ContinuousBatchScheduler { clippy::cast_possible_wrap, reason = "token counts and positions fit in i32 for llama.cpp FFI" )] - let mut pass = BatchPass::new(batch_n_tokens, max_sequences.max(1) as i32)?; + let mut pass = BatchPass::new(n_batch, max_sequences.max(1) as i32)?; assemble_phase.run(&mut pass, &mut self.active_requests)?; diff --git a/paddler/src/agent/plan_embedding_batches.rs b/paddler/src/agent/plan_embedding_batches.rs index 0172c3be..d481cf07 100644 --- a/paddler/src/agent/plan_embedding_batches.rs +++ b/paddler/src/agent/plan_embedding_batches.rs @@ -3,7 +3,7 @@ use std::ops::Range; #[must_use] pub fn plan_embedding_batches( token_counts: &[usize], - batch_n_tokens: usize, + n_batch: usize, max_sequences_per_batch: i32, ) -> Vec> { let mut batches = Vec::new(); @@ -12,7 +12,7 @@ pub fn plan_embedding_batches( let mut current_sequences: i32 = 0; for (index, &token_count) in token_counts.iter().enumerate() { - let would_exceed_tokens = current_tokens + token_count > batch_n_tokens; + let would_exceed_tokens = current_tokens + token_count > n_batch; let would_exceed_sequences = current_sequences >= max_sequences_per_batch; if (would_exceed_tokens || would_exceed_sequences) && current_sequences > 0 { diff --git a/paddler/src/balancer/compatibility/openai_service/http_route/post_chat_completions.rs b/paddler/src/balancer/compatibility/openai_service/http_route/post_chat_completions.rs index cd49387b..1abdca71 100644 --- a/paddler/src/balancer/compatibility/openai_service/http_route/post_chat_completions.rs +++ b/paddler/src/balancer/compatibility/openai_service/http_route/post_chat_completions.rs @@ -24,6 +24,7 @@ use paddler_types::jsonrpc::ResponseEnvelope; use llama_cpp_bindings::ParsedToolCall; use llama_cpp_bindings::TokenUsage; use llama_cpp_bindings::ToolCallArguments; +use paddler_types::oversized_image_details::OversizedImageDetails; use paddler_types::raw_tool_call_tokens::RawToolCallTokens; use paddler_types::request_params::continue_from_conversation_history_params::ContinueFromConversationHistoryParams; use paddler_types::request_params::continue_from_conversation_history_params::tool::Tool; @@ -96,6 +97,13 @@ fn unrecognized_tool_call_format_message(raw: &RawToolCallTokens) -> String { ) } +fn image_exceeds_batch_size_message(details: &OversizedImageDetails) -> String { + format!( + "image required {} tokens but agent n_batch is {}; rerun with a larger n_batch", + details.image_tokens, details.n_batch, + ) +} + fn arguments_to_openai_string(arguments: &ToolCallArguments) -> Result { match arguments { ToolCallArguments::ValidJson(value) => { @@ -152,6 +160,11 @@ fn try_universal_error_chunk(message: &OutgoingMessage) -> Option Some(server_error_chunk(description)), OutgoingMessage::Response(ResponseEnvelope { response, .. }) => match response { + OutgoingResponse::GeneratedToken(GeneratedTokenResult::ImageExceedsBatchSize( + details, + )) => Some(server_error_chunk(&image_exceeds_batch_size_message( + details, + ))), OutgoingResponse::GeneratedToken(token) => { description_from_error_token(token).map(server_error_chunk) } @@ -1153,6 +1166,26 @@ mod tests { Ok(()) } + #[actix_web::test] + async fn streaming_image_exceeds_batch_size_returns_error_variant() -> Result<()> { + let transformer = streaming_transformer(false); + + let message = make_token_message(GeneratedTokenResult::ImageExceedsBatchSize( + paddler_types::oversized_image_details::OversizedImageDetails { + image_tokens: 368, + n_batch: 100, + }, + )); + let chunks = transformer.transform(message).await?; + + assert_eq!(chunks.len(), 1); + assert_error_contains(&chunks[0], "368")?; + assert_error_contains(&chunks[0], "100")?; + assert_error_contains(&chunks[0], "server_error")?; + + Ok(()) + } + #[actix_web::test] async fn non_streaming_aggregates_content_only_when_no_reasoning() -> Result<()> { let transformer = non_streaming_transformer(); @@ -1377,6 +1410,26 @@ mod tests { Ok(()) } + #[actix_web::test] + async fn non_streaming_image_exceeds_batch_size_returns_error_variant() -> Result<()> { + let transformer = non_streaming_transformer(); + + let message = make_token_message(GeneratedTokenResult::ImageExceedsBatchSize( + paddler_types::oversized_image_details::OversizedImageDetails { + image_tokens: 368, + n_batch: 100, + }, + )); + let chunks = transformer.transform(message).await?; + + assert_eq!(chunks.len(), 1); + assert_error_contains(&chunks[0], "368")?; + assert_error_contains(&chunks[0], "100")?; + assert_error_contains(&chunks[0], "server_error")?; + + Ok(()) + } + #[actix_web::test] async fn non_streaming_timeout_returns_error_variant() -> Result<()> { let transformer = non_streaming_transformer(); diff --git a/paddler/src/balancer/inference_service/http_route/api/post_generate_embedding_batch.rs b/paddler/src/balancer/inference_service/http_route/api/post_generate_embedding_batch.rs index c3b5de80..3cd09da6 100644 --- a/paddler/src/balancer/inference_service/http_route/api/post_generate_embedding_batch.rs +++ b/paddler/src/balancer/inference_service/http_route/api/post_generate_embedding_batch.rs @@ -85,7 +85,7 @@ async fn respond( let mut chunk_tasks: JoinSet<()> = JoinSet::new(); for batch in params.chunk_by_input_size( - agent_desired_state.inference_parameters.batch_n_tokens + agent_desired_state.inference_parameters.n_batch * CHARACTERS_PER_TOKEN_APPROXIMATELY, ) { let buffered_request_manager_clone = app_data.buffered_request_manager.clone(); diff --git a/paddler_client_cli/src/stop_reason.rs b/paddler_client_cli/src/stop_reason.rs index ab718014..391ca9f4 100644 --- a/paddler_client_cli/src/stop_reason.rs +++ b/paddler_client_cli/src/stop_reason.rs @@ -1,5 +1,7 @@ use std::fmt; +use paddler_types::oversized_image_details::OversizedImageDetails; + #[derive(Debug)] pub enum StopReason { Completed, @@ -9,6 +11,7 @@ pub enum StopReason { GrammarRejectedModelOutput(String), GrammarSyntaxError(String), ImageDecodingFailed(String), + ImageExceedsBatchSize(OversizedImageDetails), InferenceError { code: i32, description: String }, MultimodalNotSupported(String), SamplerError(String), @@ -42,6 +45,13 @@ impl fmt::Display for StopReason { Self::ImageDecodingFailed(detail) => { write!(formatter, "image decoding failed: {detail}") } + Self::ImageExceedsBatchSize(details) => { + write!( + formatter, + "image required {} tokens but agent n_batch is {}", + details.image_tokens, details.n_batch, + ) + } Self::InferenceError { code, description } => { write!(formatter, "inference error {code}: {description}") } diff --git a/paddler_client_cli/src/streaming_response.rs b/paddler_client_cli/src/streaming_response.rs index 42a09067..eb321e53 100644 --- a/paddler_client_cli/src/streaming_response.rs +++ b/paddler_client_cli/src/streaming_response.rs @@ -86,6 +86,9 @@ impl StreamingResponse { GeneratedTokenResult::ImageDecodingFailed(detail) => { self.stop_reason = Some(StopReason::ImageDecodingFailed(detail)); } + GeneratedTokenResult::ImageExceedsBatchSize(details) => { + self.stop_reason = Some(StopReason::ImageExceedsBatchSize(details)); + } GeneratedTokenResult::MultimodalNotSupported(detail) => { self.stop_reason = Some(StopReason::MultimodalNotSupported(detail)); } diff --git a/paddler_client_javascript/src/schemas/InferenceParameters.ts b/paddler_client_javascript/src/schemas/InferenceParameters.ts index ca20c2a4..6295dec7 100644 --- a/paddler_client_javascript/src/schemas/InferenceParameters.ts +++ b/paddler_client_javascript/src/schemas/InferenceParameters.ts @@ -23,7 +23,7 @@ export const poolingTypes = [ export const InferenceParametersSchema = z .object({ - batch_n_tokens: z.number(), + n_batch: z.number(), context_size: z.number(), enable_embeddings: z.boolean(), image_resize_to_fit: z.number().int().min(1), diff --git a/paddler_client_javascript/src/schemas/InferenceServiceGenerateTokensResponse.ts b/paddler_client_javascript/src/schemas/InferenceServiceGenerateTokensResponse.ts index be7f62ae..ec0ae68b 100644 --- a/paddler_client_javascript/src/schemas/InferenceServiceGenerateTokensResponse.ts +++ b/paddler_client_javascript/src/schemas/InferenceServiceGenerateTokensResponse.ts @@ -28,6 +28,11 @@ const RawToolCallTokensSchema = z.object({ ffi_error_message: z.string(), }); +const OversizedImageDetailsSchema = z.object({ + image_tokens: z.number(), + n_batch: z.number(), +}); + const GeneratedTokenResultSchema = z.union([ z.object({ ContentToken: z.string() }), z.object({ ReasoningToken: z.string() }), @@ -40,6 +45,7 @@ const GeneratedTokenResultSchema = z.union([ z.object({ GrammarRejectedModelOutput: z.string() }), z.object({ GrammarSyntaxError: z.string() }), z.object({ ImageDecodingFailed: z.string() }), + z.object({ ImageExceedsBatchSize: OversizedImageDetailsSchema }), z.object({ MultimodalNotSupported: z.string() }), z.object({ SamplerError: z.string() }), z.object({ ToolCallParsed: z.array(ParsedToolCallSchema) }), @@ -347,6 +353,16 @@ export const InferenceServiceGenerateTokensResponseSchema = z return terminalError(request_id, generated_by, 400, variant.ImageDecodingFailed); } + if ("ImageExceedsBatchSize" in variant) { + const details = variant.ImageExceedsBatchSize; + return terminalError( + request_id, + generated_by, + 400, + `image required ${details.image_tokens} tokens but n_batch is ${details.n_batch}`, + ); + } + if ("MultimodalNotSupported" in variant) { return terminalError(request_id, generated_by, 400, variant.MultimodalNotSupported); } diff --git a/paddler_client_javascript/tests/schemas/InferenceServiceGenerateTokensResponse.test.ts b/paddler_client_javascript/tests/schemas/InferenceServiceGenerateTokensResponse.test.ts index c912cbd0..adde7b9a 100644 --- a/paddler_client_javascript/tests/schemas/InferenceServiceGenerateTokensResponse.test.ts +++ b/paddler_client_javascript/tests/schemas/InferenceServiceGenerateTokensResponse.test.ts @@ -116,3 +116,24 @@ test("UnrecognizedToolCallFormat preserves text and FFI error message", function ffi_error_message: "common_chat_parse failed: no parser", }); }); + +test("ImageExceedsBatchSize is terminal and describes token counts", function (t) { + const parsed = InferenceServiceGenerateTokensResponseSchema.parse({ + Response: { + generated_by: null, + request_id: "req-7", + response: { + GeneratedToken: { + ImageExceedsBatchSize: { image_tokens: 368, n_batch: 100 }, + }, + }, + }, + }); + + t.is(parsed.done, true); + t.is(parsed.ok, false); + t.not(parsed.error, null); + t.is(parsed.error?.code, 400); + t.true(parsed.error?.description.includes("368")); + t.true(parsed.error?.description.includes("100")); +}); diff --git a/paddler_client_python/paddler_client/inference_message.py b/paddler_client_python/paddler_client/inference_message.py index f1552e2e..6e1903f6 100644 --- a/paddler_client_python/paddler_client/inference_message.py +++ b/paddler_client_python/paddler_client/inference_message.py @@ -7,6 +7,7 @@ from typing import cast from paddler_client.embedding import Embedding +from paddler_client.oversized_image_details import OversizedImageDetails from paddler_client.parsed_tool_call import ParsedToolCall from paddler_client.raw_tool_call_tokens import RawToolCallTokens @@ -23,6 +24,7 @@ class InferenceMessageKind(StrEnum): GRAMMAR_REJECTED_MODEL_OUTPUT = "grammar_rejected_model_output" GRAMMAR_SYNTAX_ERROR = "grammar_syntax_error" IMAGE_DECODING_FAILED = "image_decoding_failed" + IMAGE_EXCEEDS_BATCH_SIZE = "image_exceeds_batch_size" MULTIMODAL_NOT_SUPPORTED = "multimodal_not_supported" REASONING_TOKEN = "reasoning_token" SAMPLER_ERROR = "sampler_error" @@ -106,6 +108,7 @@ class InferenceMessage: summary: GenerationSummary | None = None parsed_tool_calls: list[ParsedToolCall] | None = None raw_tool_call_tokens: RawToolCallTokens | None = None + oversized_image_details: OversizedImageDetails | None = None generated_by: str | None = None @property @@ -292,6 +295,19 @@ def _parse_generated_token_result( generated_by=generated_by, ) + if "ImageExceedsBatchSize" in data: + details_payload = data["ImageExceedsBatchSize"] + if not isinstance(details_payload, dict): + msg = f"ImageExceedsBatchSize payload is not a dict: {details_payload!r}" + raise ValueError(msg) + typed_details = cast("dict[str, Any]", details_payload) + return InferenceMessage( + request_id=request_id, + kind=InferenceMessageKind.IMAGE_EXCEEDS_BATCH_SIZE, + oversized_image_details=OversizedImageDetails.from_dict(typed_details), + generated_by=generated_by, + ) + for key, kind in _GENERATED_TOKEN_KINDS.items(): if key in data: return InferenceMessage( diff --git a/paddler_client_python/paddler_client/inference_parameters.py b/paddler_client_python/paddler_client/inference_parameters.py index 73d20f20..44e674f2 100644 --- a/paddler_client_python/paddler_client/inference_parameters.py +++ b/paddler_client_python/paddler_client/inference_parameters.py @@ -4,7 +4,7 @@ class InferenceParameters(BaseModel): - batch_n_tokens: int = 512 + n_batch: int = 2048 context_size: int = 8192 enable_embeddings: bool = False image_resize_to_fit: int = 1024 diff --git a/paddler_client_python/paddler_client/oversized_image_details.py b/paddler_client_python/paddler_client/oversized_image_details.py new file mode 100644 index 00000000..938a1d2c --- /dev/null +++ b/paddler_client_python/paddler_client/oversized_image_details.py @@ -0,0 +1,17 @@ +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any + + +@dataclass(frozen=True) +class OversizedImageDetails: + image_tokens: int + n_batch: int + + @classmethod + def from_dict(cls, data: dict[str, Any]) -> OversizedImageDetails: + return cls( + image_tokens=int(data["image_tokens"]), + n_batch=int(data["n_batch"]), + ) diff --git a/paddler_client_python/tests/test_client_management.py b/paddler_client_python/tests/test_client_management.py index fdacccfa..603f2903 100644 --- a/paddler_client_python/tests/test_client_management.py +++ b/paddler_client_python/tests/test_client_management.py @@ -90,7 +90,7 @@ async def test_get_balancer_desired_state_deserializes() -> None: response_data = { "chat_template_override": None, "inference_parameters": { - "batch_n_tokens": 512, + "n_batch": 512, "context_size": 8192, "enable_embeddings": False, "image_resize_to_fit": 1024, diff --git a/paddler_client_python/tests/test_inference_message.py b/paddler_client_python/tests/test_inference_message.py index 96760b92..cff84440 100644 --- a/paddler_client_python/tests/test_inference_message.py +++ b/paddler_client_python/tests/test_inference_message.py @@ -41,13 +41,13 @@ def test_parse_tool_call_token_response() -> None: data = { "Response": { "request_id": "req-1", - "response": {"GeneratedToken": {"ToolCallToken": "{\"name\":"}}, + "response": {"GeneratedToken": {"ToolCallToken": '{"name":'}}, } } message = parse_inference_client_message(data) assert message.kind == InferenceMessageKind.TOOL_CALL_TOKEN - assert message.token == "{\"name\":" + assert message.token == '{"name":' assert message.is_token assert not message.is_terminal @@ -186,6 +186,46 @@ def test_parse_unrecognized_tool_call_format_with_non_dict_payload_raises() -> N parse_inference_client_message(data) +def test_parse_image_exceeds_batch_size_response_carries_token_counts() -> None: + data = { + "Response": { + "request_id": "req-1", + "response": { + "GeneratedToken": { + "ImageExceedsBatchSize": { + "image_tokens": 368, + "n_batch": 100, + }, + }, + }, + }, + } + message = parse_inference_client_message(data) + + assert message.kind == InferenceMessageKind.IMAGE_EXCEEDS_BATCH_SIZE + assert message.oversized_image_details is not None + assert message.oversized_image_details.image_tokens == 368 + assert message.oversized_image_details.n_batch == 100 + assert not message.is_token + + +def test_parse_image_exceeds_batch_size_with_non_dict_payload_raises() -> None: + data = { + "Response": { + "request_id": "req-1", + "response": { + "GeneratedToken": {"ImageExceedsBatchSize": "scalar payload"}, + }, + }, + } + + with pytest.raises( + ValueError, + match="ImageExceedsBatchSize payload is not a dict", + ): + parse_inference_client_message(data) + + def test_parse_undeterminable_token_response() -> None: data = { "Response": { @@ -294,11 +334,7 @@ def test_parse_grammar_incompatible_with_thinking() -> None: data = { "Response": { "request_id": "req-1", - "response": { - "GeneratedToken": { - "GrammarIncompatibleWithThinking": "err" - } - }, + "response": {"GeneratedToken": {"GrammarIncompatibleWithThinking": "err"}}, } } message = parse_inference_client_message(data) @@ -313,9 +349,7 @@ def test_parse_grammar_initialization_failed() -> None: "Response": { "request_id": "req-1", "response": { - "GeneratedToken": { - "GrammarInitializationFailed": "null grammar" - } + "GeneratedToken": {"GrammarInitializationFailed": "null grammar"} }, } } @@ -331,9 +365,7 @@ def test_parse_grammar_rejected_model_output() -> None: "Response": { "request_id": "req-1", "response": { - "GeneratedToken": { - "GrammarRejectedModelOutput": "token rejected" - } + "GeneratedToken": {"GrammarRejectedModelOutput": "token rejected"} }, } } @@ -348,9 +380,7 @@ def test_parse_grammar_syntax_error() -> None: data = { "Response": { "request_id": "req-1", - "response": { - "GeneratedToken": {"GrammarSyntaxError": "invalid schema"} - }, + "response": {"GeneratedToken": {"GrammarSyntaxError": "invalid schema"}}, } } message = parse_inference_client_message(data) @@ -367,7 +397,7 @@ def test_parse_tool_call_validator_build_failed_response_carries_error() -> None "response": { "GeneratedToken": { "ToolCallValidatorBuildFailed": ( - "tool \"get_weather\" parameters are not a valid JSON Schema" + 'tool "get_weather" parameters are not a valid JSON Schema' ), }, }, @@ -377,7 +407,7 @@ def test_parse_tool_call_validator_build_failed_response_carries_error() -> None assert message.kind == InferenceMessageKind.TOOL_CALL_VALIDATOR_BUILD_FAILED assert message.error_message == ( - "tool \"get_weather\" parameters are not a valid JSON Schema" + 'tool "get_weather" parameters are not a valid JSON Schema' ) assert message.is_terminal diff --git a/paddler_client_python/tests/test_tool.py b/paddler_client_python/tests/test_tool.py index 8c53127d..bf96cfc2 100644 --- a/paddler_client_python/tests/test_tool.py +++ b/paddler_client_python/tests/test_tool.py @@ -27,11 +27,13 @@ def test_tool_with_parameters_serialization() -> None: function=Function( name="get_weather", description="Get weather", - parameters=ValidatedParametersSchema.model_validate({ - "type": "object", - "properties": {"location": {"type": "string"}}, - "required": ["location"], - }), + parameters=ValidatedParametersSchema.model_validate( + { + "type": "object", + "properties": {"location": {"type": "string"}}, + "required": ["location"], + } + ), ) ) dumped = tool.model_dump( diff --git a/paddler_tests/src/lib.rs b/paddler_tests/src/lib.rs index beb2cfc5..a041d814 100644 --- a/paddler_tests/src/lib.rs +++ b/paddler_tests/src/lib.rs @@ -36,6 +36,7 @@ pub mod start_in_process_cluster_with_qwen3_5; pub mod start_in_process_cluster_with_qwen3_6; pub mod start_in_process_cluster_with_qwen3_6_and_mmproj; pub mod start_in_process_cluster_with_smolvlm2; +pub mod start_in_process_cluster_with_smolvlm2_and_n_batch; pub mod start_in_process_embedding_cluster; pub mod start_subprocess_cluster; pub mod start_subprocess_cluster_with_qwen2_5_vl; diff --git a/paddler_tests/src/start_in_process_cluster_with_smolvlm2_and_n_batch.rs b/paddler_tests/src/start_in_process_cluster_with_smolvlm2_and_n_batch.rs new file mode 100644 index 00000000..fa4fd3f0 --- /dev/null +++ b/paddler_tests/src/start_in_process_cluster_with_smolvlm2_and_n_batch.rs @@ -0,0 +1,46 @@ +use anyhow::Result; +use paddler_types::agent_desired_model::AgentDesiredModel; +use paddler_types::balancer_desired_state::BalancerDesiredState; + +use crate::cluster_handle::ClusterHandle; +use crate::current_test_device::current_test_device; +use crate::in_process_cluster_params::InProcessClusterParams; +use crate::model_card::ModelCard; +use crate::model_card::smolvlm2_256m::smolvlm2_256m; +use crate::model_card::smolvlm2_256m_mmproj::smolvlm2_256m_mmproj; +use crate::start_in_process_cluster::start_in_process_cluster; + +pub async fn start_in_process_cluster_with_smolvlm2_and_n_batch( + slots_per_agent: i32, + n_batch: usize, +) -> Result { + let device = current_test_device()?; + + device.require_available()?; + + let ModelCard { + gpu_layer_count, + reference: primary_reference, + } = smolvlm2_256m(); + let ModelCard { + reference: mmproj_reference, + .. + } = smolvlm2_256m_mmproj(); + + let mut inference_parameters = device.inference_parameters_for_full_offload(gpu_layer_count); + inference_parameters.n_batch = n_batch; + + start_in_process_cluster(InProcessClusterParams { + slots_per_agent, + desired_state: BalancerDesiredState { + chat_template_override: None, + inference_parameters, + model: AgentDesiredModel::HuggingFace(primary_reference), + multimodal_projection: AgentDesiredModel::HuggingFace(mmproj_reference), + use_chat_template_override: false, + }, + wait_for_slots_ready: true, + ..InProcessClusterParams::default() + }) + .await +} diff --git a/paddler_tests/tests/agent_completes_generation_with_adequate_n_batch.rs b/paddler_tests/tests/agent_completes_generation_with_adequate_n_batch.rs new file mode 100644 index 00000000..6c1babac --- /dev/null +++ b/paddler_tests/tests/agent_completes_generation_with_adequate_n_batch.rs @@ -0,0 +1,96 @@ +#![cfg(feature = "tests_that_use_llms")] + +use std::fs; + +use anyhow::Context as _; +use anyhow::Result; +use base64::Engine as _; +use base64::engine::general_purpose::STANDARD as BASE64_STANDARD; +use paddler_tests::collect_generated_tokens::collect_generated_tokens; +use paddler_tests::inference_http_client::InferenceHttpClient; +use paddler_tests::start_in_process_cluster_with_smolvlm2::start_in_process_cluster_with_smolvlm2; +use paddler_types::conversation_history::ConversationHistory; +use paddler_types::conversation_message::ConversationMessage; +use paddler_types::conversation_message_content::ConversationMessageContent; +use paddler_types::conversation_message_content_part::ConversationMessageContentPart; +use paddler_types::image_url::ImageUrl; +use paddler_types::request_params::continue_from_conversation_history_params::ContinueFromConversationHistoryParams; +use reqwest::Client; + +fn load_fixture_as_data_uri(fixture_name: &str, mime_type: &str) -> Result { + let fixture_path = format!( + "{}/../fixtures/{fixture_name}", + env!("CARGO_MANIFEST_DIR") + ); + let bytes = fs::read(&fixture_path) + .with_context(|| format!("failed to read test fixture {fixture_path}"))?; + let encoded = BASE64_STANDARD.encode(&bytes); + + Ok(format!("data:{mime_type};base64,{encoded}")) +} + +async fn drive_normal_image_fixture( + inference_client: &InferenceHttpClient, + fixture_name: &str, + mime_type: &str, +) -> Result<()> { + let image_data_uri = load_fixture_as_data_uri(fixture_name, mime_type)?; + + let stream = inference_client + .post_continue_from_conversation_history(&ContinueFromConversationHistoryParams { + add_generation_prompt: true, + conversation_history: ConversationHistory::new(vec![ConversationMessage { + content: ConversationMessageContent::Parts(vec![ + ConversationMessageContentPart::ImageUrl { + image_url: ImageUrl { + url: image_data_uri, + }, + }, + ConversationMessageContentPart::Text { + text: "What do you see in this image?".to_owned(), + }, + ]), + role: "user".to_owned(), + }]), + enable_thinking: false, + grammar: None, + max_tokens: 20, + parse_tool_calls: false, + tools: vec![], + }) + .await?; + + let collected = collect_generated_tokens(stream).await?; + + let saw_token = collected + .token_results + .iter() + .any(|result| result.token_result.is_token()); + + assert!( + saw_token, + "fixture {fixture_name} should produce at least one content/reasoning/tool-call token with adequate n_batch; got {:?}", + collected + .token_results + .iter() + .map(|result| &result.token_result) + .collect::>(), + ); + + Ok(()) +} + +#[serial_test::file_serial(model_load, path => "../target/model_load.lock")] +#[tokio::test(flavor = "multi_thread")] +async fn agent_completes_generation_with_adequate_n_batch() -> Result<()> { + let cluster = start_in_process_cluster_with_smolvlm2(1).await?; + let inference_client = + InferenceHttpClient::new(Client::new(), cluster.addresses.inference_base_url()?); + + drive_normal_image_fixture(&inference_client, "sarnow.jpeg", "image/jpeg").await?; + drive_normal_image_fixture(&inference_client, "llamas.webp", "image/webp").await?; + + cluster.shutdown().await?; + + Ok(()) +} diff --git a/paddler_tests/tests/agent_does_not_crash_on_oversized_image.rs b/paddler_tests/tests/agent_does_not_crash_on_oversized_image.rs new file mode 100644 index 00000000..7a0826ab --- /dev/null +++ b/paddler_tests/tests/agent_does_not_crash_on_oversized_image.rs @@ -0,0 +1,99 @@ +#![cfg(feature = "tests_that_use_llms")] + +use std::fs; + +use anyhow::Context as _; +use anyhow::Result; +use base64::Engine as _; +use base64::engine::general_purpose::STANDARD as BASE64_STANDARD; +use paddler_tests::collect_generated_tokens::collect_generated_tokens; +use paddler_tests::inference_http_client::InferenceHttpClient; +use paddler_tests::start_in_process_cluster_with_smolvlm2_and_n_batch::start_in_process_cluster_with_smolvlm2_and_n_batch; +use paddler_types::conversation_history::ConversationHistory; +use paddler_types::conversation_message::ConversationMessage; +use paddler_types::conversation_message_content::ConversationMessageContent; +use paddler_types::conversation_message_content_part::ConversationMessageContentPart; +use paddler_types::generated_token_result::GeneratedTokenResult; +use paddler_types::image_url::ImageUrl; +use paddler_types::request_params::continue_from_conversation_history_params::ContinueFromConversationHistoryParams; +use reqwest::Client; + +fn load_fixture_as_data_uri(fixture_name: &str, mime_type: &str) -> Result { + let fixture_path = format!( + "{}/../fixtures/{fixture_name}", + env!("CARGO_MANIFEST_DIR") + ); + let bytes = fs::read(&fixture_path) + .with_context(|| format!("failed to read test fixture {fixture_path}"))?; + let encoded = BASE64_STANDARD.encode(&bytes); + + Ok(format!("data:{mime_type};base64,{encoded}")) +} + +async fn drive_oversized_image_fixture( + inference_client: &InferenceHttpClient, + fixture_name: &str, + mime_type: &str, +) -> Result<()> { + let image_data_uri = load_fixture_as_data_uri(fixture_name, mime_type)?; + + let stream = inference_client + .post_continue_from_conversation_history(&ContinueFromConversationHistoryParams { + add_generation_prompt: true, + conversation_history: ConversationHistory::new(vec![ConversationMessage { + content: ConversationMessageContent::Parts(vec![ + ConversationMessageContentPart::ImageUrl { + image_url: ImageUrl { + url: image_data_uri, + }, + }, + ConversationMessageContentPart::Text { + text: "Describe this image.".to_owned(), + }, + ]), + role: "user".to_owned(), + }]), + enable_thinking: false, + grammar: None, + max_tokens: 20, + parse_tool_calls: false, + tools: vec![], + }) + .await?; + + let collected = collect_generated_tokens(stream).await?; + + let saw_oversized = collected.token_results.iter().any(|result| { + matches!( + result.token_result, + GeneratedTokenResult::ImageExceedsBatchSize(_), + ) + }); + + assert!( + saw_oversized, + "fixture {fixture_name} must produce GeneratedTokenResult::ImageExceedsBatchSize when n_batch < image tokens; got {:?}", + collected + .token_results + .iter() + .map(|result| &result.token_result) + .collect::>(), + ); + + Ok(()) +} + +#[serial_test::file_serial(model_load, path => "../target/model_load.lock")] +#[tokio::test(flavor = "multi_thread")] +async fn agent_does_not_crash_on_oversized_image() -> Result<()> { + let cluster = start_in_process_cluster_with_smolvlm2_and_n_batch(1, 32).await?; + let inference_client = + InferenceHttpClient::new(Client::new(), cluster.addresses.inference_base_url()?); + + drive_oversized_image_fixture(&inference_client, "sarnow.jpeg", "image/jpeg").await?; + drive_oversized_image_fixture(&inference_client, "llamas.webp", "image/webp").await?; + + cluster.shutdown().await?; + + Ok(()) +} diff --git a/paddler_tests/tests/agent_embedding_batch_distribution_independent_of_context_size.rs b/paddler_tests/tests/agent_embedding_batch_distribution_independent_of_context_size.rs index 468c70b9..e59d2c2c 100644 --- a/paddler_tests/tests/agent_embedding_batch_distribution_independent_of_context_size.rs +++ b/paddler_tests/tests/agent_embedding_batch_distribution_independent_of_context_size.rs @@ -17,7 +17,7 @@ use reqwest::Client; async fn agent_embedding_batch_distribution_independent_of_context_size() -> Result<()> { let cluster = start_in_process_embedding_cluster( InferenceParameters { - batch_n_tokens: 64, + n_batch: 64, context_size: 512, enable_embeddings: true, ..InferenceParameters::default() diff --git a/paddler_tests/tests/continuous_batch_evicts_long_sequence_under_kv_pressure.rs b/paddler_tests/tests/continuous_batch_evicts_long_sequence_under_kv_pressure.rs index d7fbdb9a..bbf69981 100644 --- a/paddler_tests/tests/continuous_batch_evicts_long_sequence_under_kv_pressure.rs +++ b/paddler_tests/tests/continuous_batch_evicts_long_sequence_under_kv_pressure.rs @@ -29,7 +29,7 @@ async fn continuous_batch_evicts_long_sequence_under_kv_pressure() -> Result<()> let mut inference_parameters = device.inference_parameters_for_full_offload(gpu_layer_count); - inference_parameters.batch_n_tokens = 256; + inference_parameters.n_batch = 256; inference_parameters.context_size = 256; inference_parameters.temperature = 0.0; diff --git a/paddler_types/src/generated_token_result.rs b/paddler_types/src/generated_token_result.rs index 1f12d764..63abed30 100644 --- a/paddler_types/src/generated_token_result.rs +++ b/paddler_types/src/generated_token_result.rs @@ -4,6 +4,7 @@ use serde::Serialize; use llama_cpp_bindings_types::ParsedToolCall; use crate::generation_summary::GenerationSummary; +use crate::oversized_image_details::OversizedImageDetails; use crate::raw_tool_call_tokens::RawToolCallTokens; use crate::streamable_result::StreamableResult; @@ -18,6 +19,7 @@ pub enum GeneratedTokenResult { GrammarRejectedModelOutput(String), GrammarSyntaxError(String), ImageDecodingFailed(String), + ImageExceedsBatchSize(OversizedImageDetails), MultimodalNotSupported(String), ReasoningToken(String), SamplerError(String), @@ -78,6 +80,7 @@ impl StreamableResult for GeneratedTokenResult { | Self::GrammarRejectedModelOutput(_) | Self::GrammarSyntaxError(_) | Self::ImageDecodingFailed(_) + | Self::ImageExceedsBatchSize(_) | Self::MultimodalNotSupported(_) | Self::SamplerError(_) | Self::ToolSchemaInvalid(_) @@ -124,6 +127,18 @@ mod tests { assert!(GeneratedTokenResult::ImageDecodingFailed("err".to_owned()).is_done()); } + #[test] + fn image_exceeds_batch_size_is_done_and_not_classified_as_token() { + let event = GeneratedTokenResult::ImageExceedsBatchSize(OversizedImageDetails { + image_tokens: 368, + n_batch: 100, + }); + + assert!(event.is_done()); + assert!(!event.is_token()); + assert!(event.token_text().is_none()); + } + #[test] fn multimodal_not_supported_is_done() { assert!(GeneratedTokenResult::MultimodalNotSupported("err".to_owned()).is_done()); diff --git a/paddler_types/src/inference_parameters.rs b/paddler_types/src/inference_parameters.rs index dd86047b..b29e394c 100644 --- a/paddler_types/src/inference_parameters.rs +++ b/paddler_types/src/inference_parameters.rs @@ -10,7 +10,7 @@ use crate::validates::Validates; #[derive(Clone, Debug, Deserialize, PartialEq, Serialize)] #[serde(deny_unknown_fields)] pub struct InferenceParameters { - pub batch_n_tokens: usize, + pub n_batch: usize, pub context_size: u32, pub enable_embeddings: bool, pub image_resize_to_fit: u32, @@ -49,7 +49,7 @@ impl Validates for InferenceParameters { impl Default for InferenceParameters { fn default() -> Self { Self { - batch_n_tokens: 512, + n_batch: 2048, context_size: 8192, enable_embeddings: false, image_resize_to_fit: 1024, diff --git a/paddler_types/src/lib.rs b/paddler_types/src/lib.rs index 823c0b84..78f971ed 100644 --- a/paddler_types/src/lib.rs +++ b/paddler_types/src/lib.rs @@ -33,6 +33,7 @@ pub mod kv_cache_dtype; pub mod media_marker; pub mod model_metadata; pub mod normalization; +pub mod oversized_image_details; pub mod pooling_type; pub mod raw_tool_call_tokens; pub mod request_params; diff --git a/paddler_types/src/oversized_image_details.rs b/paddler_types/src/oversized_image_details.rs new file mode 100644 index 00000000..08240d75 --- /dev/null +++ b/paddler_types/src/oversized_image_details.rs @@ -0,0 +1,9 @@ +use serde::Deserialize; +use serde::Serialize; + +#[derive(Debug, Deserialize, Serialize)] +#[serde(deny_unknown_fields)] +pub struct OversizedImageDetails { + pub image_tokens: u32, + pub n_batch: u32, +} diff --git a/resources/ts/components/ChangeModelForm.tsx b/resources/ts/components/ChangeModelForm.tsx index 8e296245..b627197f 100644 --- a/resources/ts/components/ChangeModelForm.tsx +++ b/resources/ts/components/ChangeModelForm.tsx @@ -234,7 +234,7 @@ export function ChangeModelForm({ Date: Tue, 12 May 2026 16:41:22 +0200 Subject: [PATCH 32/51] Distribute embedding batches evenly across agents up to a configurable per-chunk cap; consolidate test agents into AgentConfig --- .../continuous_batch_embedding_processor.rs | 42 ++- .../agent/continuous_batch_scheduler/mod.rs | 4 +- .../balancer/inference_service/app_data.rs | 2 + .../api/post_generate_embedding_batch.rs | 15 +- paddler/src/balancer/inference_service/mod.rs | 3 + .../src/bootstrapped_balancer_handle.rs | 1 + .../src/schemas/InferenceParameters.ts | 1 + paddler_tests/src/agent_config.rs | 25 ++ paddler_tests/src/agents_stream_watcher.rs | 34 +- .../src/collect_embedding_results.rs | 20 +- .../src/collected_embedding_results.rs | 6 + .../src/in_process_cluster_params.rs | 13 +- paddler_tests/src/lib.rs | 2 + .../src/qwen3_embedding_cluster_params.rs | 23 ++ paddler_tests/src/start_in_process_cluster.rs | 24 +- ...uster_with_deepseek_r1_distill_llama_8b.rs | 5 +- .../start_in_process_cluster_with_gemma_4.rs | 5 +- ...process_cluster_with_gemma_4_and_mmproj.rs | 5 +- ...t_in_process_cluster_with_glm_4_7_flash.rs | 5 +- ...art_in_process_cluster_with_ministral_3.rs | 5 +- ...ess_cluster_with_ministral_3_and_mmproj.rs | 5 +- ...tart_in_process_cluster_with_qwen2_5_vl.rs | 7 +- .../start_in_process_cluster_with_qwen3.rs | 5 +- .../start_in_process_cluster_with_qwen3_5.rs | 5 +- .../start_in_process_cluster_with_qwen3_6.rs | 5 +- ...process_cluster_with_qwen3_6_and_mmproj.rs | 5 +- .../start_in_process_cluster_with_smolvlm2.rs | 5 +- ...ocess_cluster_with_smolvlm2_and_n_batch.rs | 5 +- .../src/start_in_process_embedding_cluster.rs | 5 +- paddler_tests/src/start_subprocess_cluster.rs | 23 +- ...tart_subprocess_cluster_with_qwen2_5_vl.rs | 7 +- .../start_subprocess_cluster_with_qwen3.rs | 7 +- ...subprocess_cluster_with_qwen3_embedding.rs | 16 +- .../start_subprocess_cluster_with_smolvlm2.rs | 7 +- .../src/subprocess_cluster_params.rs | 10 +- ..._embedding_batch_larger_than_slot_count.rs | 3 +- ...pletes_generation_with_adequate_n_batch.rs | 8 +- ...t_conversation_accepts_empty_tools_list.rs | 3 +- ...onversation_history_respects_max_tokens.rs | 3 +- ...onversation_with_function_tool_succeeds.rs | 3 +- ...ion_with_gbnf_grammar_constrains_output.rs | 3 +- ..._json_schema_grammar_returns_valid_json.rs | 3 +- ...ersation_without_grammar_field_succeeds.rs | 3 +- ...agent_does_not_crash_on_oversized_image.rs | 9 +- ...istribution_independent_of_context_size.rs | 3 +- ...eturns_one_embedding_per_input_document.rs | 3 +- ...gent_embedding_document_exceeds_n_batch.rs | 100 ++++++ ...mension_across_inputs_of_varying_length.rs | 3 +- ..._on_sigterm_during_multimodal_inference.rs | 2 +- ...s_cleanly_when_killed_during_generation.rs | 2 +- ...ith_thinking_returns_incompatible_error.rs | 3 +- ...oncurrent_embedding_requests_per_client.rs | 3 +- ...l2_normalized_embeddings_have_unit_norm.rs | 3 +- ..._completions_non_streaming_returns_text.rs | 3 +- ...at_completions_streaming_returns_chunks.rs | 3 +- .../agent_raw_prompt_respects_max_tokens.rs | 3 +- ...mpt_with_gbnf_grammar_constrains_output.rs | 3 +- ...w_prompt_without_grammar_field_succeeds.rs | 3 +- ...l_with_invalid_required_field_in_schema.rs | 3 +- ..._slot_when_websocket_client_disconnects.rs | 3 +- ...ot_start_for_excessive_slots_in_process.rs | 7 +- ...ot_start_for_excessive_slots_subprocess.rs | 4 +- ..._metal_quantized_distinct_kv_in_process.rs | 7 +- ..._metal_quantized_distinct_kv_subprocess.rs | 4 +- ...ical_embeddings_for_identical_documents.rs | 3 +- ...image_decoding_error_for_invalid_base64.rs | 3 +- ...e_decoding_error_for_malformed_data_uri.rs | 3 +- ...rns_image_decoding_error_for_remote_url.rs | 3 +- ...ms_normalized_embeddings_when_requested.rs | 3 +- ..._unnormalized_embeddings_when_requested.rs | 3 +- ...our_concurrent_clients_streaming_tokens.rs | 3 +- ...ens_from_conversation_history_over_http.rs | 3 +- ...gent_streams_tokens_from_image_data_uri.rs | 3 +- .../agent_streams_tokens_from_raw_prompt.rs | 3 +- ...ent_text_only_model_rejects_image_input.rs | 3 +- ..._closes_management_websocket_on_sigterm.rs | 2 +- ...etes_buffered_request_after_agent_joins.rs | 2 +- ...in_flight_inference_during_model_switch.rs | 3 +- ...tes_buffered_requests_across_two_agents.rs | 14 +- ...stributes_embedding_batch_across_agents.rs | 12 +- ...g_batch_across_agents_with_uneven_slots.rs | 97 +++++ ...es_embedding_burst_evenly_across_agents.rs | 15 +- ...ibutes_token_burst_evenly_across_agents.rs | 5 +- ...ing_burst_exceeds_max_buffered_requests.rs | 98 +++++ ..._fans_out_embedding_batch_to_all_agents.rs | 12 +- ..._subscriber_completes_within_one_second.rs | 2 +- .../balancer_inference_health_returns_ok.rs | 2 +- ...ice_replies_with_configured_cors_origin.rs | 2 +- ...ice_replies_with_configured_cors_origin.rs | 2 +- ...r_memory_storage_persists_desired_state.rs | 2 +- ...alancer_openai_compat_health_returns_ok.rs | 2 +- ...s_chat_template_override_across_restart.rs | 4 +- ...r_persists_desired_state_across_restart.rs | 4 +- ...sts_huggingface_mmproj_in_desired_state.rs | 2 +- ...ists_local_mmproj_path_in_desired_state.rs | 2 +- ...lancer_persists_model_switch_in_storage.rs | 2 +- ...cer_registers_multiple_agents_over_time.rs | 2 +- ...late_does_not_compile_for_invalid_jinja.rs | 4 +- ...eports_huggingface_model_does_not_exist.rs | 4 +- ...mproj_cannot_be_loaded_for_invalid_file.rs | 4 +- ...model_cannot_be_loaded_for_corrupt_file.rs | 4 +- ...model_cannot_be_loaded_for_invalid_gguf.rs | 4 +- ...ancer_reports_model_file_does_not_exist.rs | 4 +- ..._multimodal_projection_cannot_be_loaded.rs | 4 +- ..._find_chat_template_for_embedding_model.rs | 4 +- ...es_buffered_requests_after_agent_killed.rs | 8 +- ...rns_503_when_request_buffering_disabled.rs | 2 +- ...504_when_inference_item_timeout_is_zero.rs | 4 +- ...r_returns_504_when_no_agents_registered.rs | 2 +- ...er_returns_504_when_no_model_configured.rs | 2 +- ...est_after_agent_with_capacity_registers.rs | 2 +- ..._drains_in_flight_inference_before_swap.rs | 4 +- ...ate_override_applied_to_embedding_model.rs | 4 +- ...emplate_override_replaces_model_builtin.rs | 4 +- ..._template_swaps_between_inference_calls.rs | 4 +- ..._conversation_history_requests_complete.rs | 3 +- .../tests/continuous_batch_distinct_output.rs | 7 +- ..._evicts_long_sequence_under_kv_pressure.rs | 7 +- ...kens_with_distinct_k_and_v_cache_dtypes.rs | 7 +- ...rates_tokens_with_partial_layer_offload.rs | 7 +- ...and_short_prompts_complete_concurrently.rs | 3 +- ...h_plain_and_multimodal_run_concurrently.rs | 3 +- ...ects_embedding_during_active_generation.rs | 3 +- ...ects_second_request_when_only_slot_busy.rs | 7 +- ...h_releases_slot_when_client_disconnects.rs | 3 +- ...s_slots_on_shutdown_with_active_request.rs | 3 +- ...tch_reuses_slot_after_request_completes.rs | 3 +- ...s_batch_serves_four_concurrent_requests.rs | 3 +- paddler_tests/tests/continuous_batch_smoke.rs | 7 +- ...terminates_generation_before_max_tokens.rs | 3 +- ...uous_batch_stops_at_max_tokens_boundary.rs | 3 +- ...ops_generation_when_stop_sender_dropped.rs | 3 +- ...rent_multimodal_requests_produce_tokens.rs | 3 +- ...nternal_endpoint_emits_reasoning_tokens.rs | 4 +- ...when_embeddings_disabled_in_parameters.rs} | 41 +-- ...nternal_endpoint_emits_reasoning_tokens.rs | 3 +- ...mits_reasoning_tokens_for_image_request.rs | 3 +- ...l_endpoint_emits_tool_call_parsed_event.rs | 3 +- ...nternal_endpoint_emits_reasoning_tokens.rs | 3 +- ...l_endpoint_emits_tool_call_parsed_event.rs | 3 +- paddler_tests/tests/harness_agents_watcher.rs | 2 +- .../harness_in_process_cluster_shutdown.rs | 3 +- .../harness_subprocess_cluster_shutdown.rs | 5 +- ...t_agents_stream_yields_initial_snapshot.rs | 3 +- ...requests_stream_yields_initial_snapshot.rs | 2 +- .../management_health_endpoint_returns_ok.rs | 2 +- ...rics_endpoint_exposes_prometheus_gauges.rs | 2 +- ...o_download_progress_after_load_complete.rs | 3 +- ...returns_model_metadata_for_loaded_agent.rs | 3 +- ..._subscribers_receive_slot_usage_updates.rs | 7 +- ...nternal_endpoint_emits_reasoning_tokens.rs | 3 +- ...mits_reasoning_tokens_for_image_request.rs | 4 +- ...l_endpoint_emits_tool_call_parsed_event.rs | 3 +- ...n25vl_generates_tokens_from_image_input.rs | 3 +- ..._tokens_for_long_system_and_user_prompt.rs | 3 +- ...neration_stops_at_eog_before_max_tokens.rs | 3 +- ...mits_reasoning_tokens_for_image_request.rs | 3 +- ...l_endpoint_emits_tool_call_parsed_event.rs | 3 +- ...king_disabled_emits_only_content_tokens.rs | 3 +- ...thinking_enabled_emits_reasoning_tokens.rs | 3 +- ...ng_mode_stops_cleanly_before_max_tokens.rs | 3 +- ...g_multi_turn_conversation_stops_cleanly.rs | 3 +- ...with_mmproj_generates_tokens_from_image.rs | 3 +- ..._system_message_completes_with_thinking.rs | 3 +- ...stem_message_completes_without_thinking.rs | 3 +- ...cts_image_with_multimodal_not_supported.rs | 3 +- ...mits_reasoning_tokens_for_image_request.rs | 3 +- ...l_endpoint_emits_tool_call_parsed_event.rs | 3 +- ...king_disabled_emits_only_content_tokens.rs | 3 +- ...thinking_enabled_emits_reasoning_tokens.rs | 3 +- ..._grammar_constrains_output_to_yes_or_no.rs | 3 +- ...erates_tokens_from_conversation_history.rs | 3 +- .../qwen3_generates_tokens_from_raw_prompt.rs | 3 +- ...ith_thinking_returns_incompatible_error.rs | 3 +- ...t_concurrent_requests_independent_usage.rs | 3 +- ...l_endpoint_emits_tool_call_parsed_event.rs | 3 +- ...nternal_endpoint_emits_tool_call_tokens.rs | 3 +- ...ernal_endpoint_max_tokens_usage_matches.rs | 3 +- ...n3_internal_endpoint_pure_content_usage.rs | 3 +- ...without_parse_flag_emit_only_raw_tokens.rs | 3 +- ...king_disabled_emits_no_reasoning_tokens.rs | 3 +- ...thinking_enabled_emits_reasoning_tokens.rs | 3 +- ..._json_schema_grammar_returns_valid_json.rs | 3 +- ...ming_emits_tool_calls_for_function_tool.rs | 3 +- ...wen3_openai_non_streaming_returns_usage.rs | 3 +- ...nai_non_streaming_usage_with_tool_calls.rs | 3 +- ...ming_emits_tool_calls_for_function_tool.rs | 3 +- ...ai_streaming_emits_usage_when_requested.rs | 3 +- ...treaming_omits_usage_when_not_requested.rs | 3 +- ...g_routes_reasoning_to_reasoning_content.rs | 3 +- ...streaming_usage_breakdown_with_thinking.rs | 3 +- ..._grammar_generates_unconstrained_output.rs | 3 +- ...lvlm2_generates_tokens_from_image_input.rs | 3 +- paddler_types/src/embedding_result.rs | 21 +- paddler_types/src/inference_parameters.rs | 23 ++ paddler_types/src/jsonrpc/error.rs | 2 +- paddler_types/src/lib.rs | 1 + .../oversized_embedding_document_details.rs | 10 + .../chunk_by_input_size_iter.rs | 45 --- .../generate_embedding_batch_params/mod.rs | 340 ++++++++++++++---- resources/ts/components/AgentListStream.tsx | 2 +- .../ts/components/BufferedRequestsStream.tsx | 2 +- resources/ts/components/ChangeModelForm.tsx | 6 +- resources/ts/components/ChangeModelPage.tsx | 2 +- .../ChatTemplateContextProvider.tsx | 2 +- .../ts/components/ChatTemplateEditButton.tsx | 2 +- .../components/ChatTemplateOverrideLoader.tsx | 2 +- ...nversationMessagePromptGeneratedTokens.tsx | 6 +- .../InferenceParameterCacheDtype.tsx | 2 +- .../components/InferenceParameterCheckbox.tsx | 5 +- .../ts/components/InferenceParameterInput.tsx | 11 +- .../InferenceParameterPoolingType.tsx | 2 +- .../InferenceParametersContextProvider.tsx | 2 +- resources/ts/components/ModelMetadata.tsx | 2 +- .../ts/components/ModelMetadataLoader.tsx | 2 +- resources/ts/components/PromptPage.tsx | 2 +- resources/ts/hooks/useEventSourceUpdates.ts | 5 +- resources/ts/hooks/useFetchJson.ts | 5 +- resources/ts/hooks/useWebSocket.ts | 11 +- 219 files changed, 1273 insertions(+), 491 deletions(-) create mode 100644 paddler_tests/src/agent_config.rs create mode 100644 paddler_tests/src/qwen3_embedding_cluster_params.rs create mode 100644 paddler_tests/tests/agent_embedding_document_exceeds_n_batch.rs create mode 100644 paddler_tests/tests/balancer_distributes_embedding_batch_across_agents_with_uneven_slots.rs create mode 100644 paddler_tests/tests/balancer_emits_overflow_errors_when_embedding_burst_exceeds_max_buffered_requests.rs rename paddler_tests/tests/{agent_returns_error_when_embeddings_disabled_in_parameters.rs => endpoint_rejects_embedding_request_when_embeddings_disabled_in_parameters.rs} (64%) create mode 100644 paddler_types/src/oversized_embedding_document_details.rs delete mode 100644 paddler_types/src/request_params/generate_embedding_batch_params/chunk_by_input_size_iter.rs diff --git a/paddler/src/agent/continuous_batch_embedding_processor.rs b/paddler/src/agent/continuous_batch_embedding_processor.rs index a75a461b..a74dd625 100644 --- a/paddler/src/agent/continuous_batch_embedding_processor.rs +++ b/paddler/src/agent/continuous_batch_embedding_processor.rs @@ -6,9 +6,11 @@ use anyhow::anyhow; use llama_cpp_bindings::context::LlamaContext; use llama_cpp_bindings::llama_batch::LlamaBatch; use llama_cpp_bindings::model::AddBos; +use log::warn; use paddler_types::embedding::Embedding; use paddler_types::embedding_normalization_method::EmbeddingNormalizationMethod; use paddler_types::embedding_result::EmbeddingResult; +use paddler_types::oversized_embedding_document_details::OversizedEmbeddingDocumentDetails; use paddler_types::request_params::GenerateEmbeddingBatchParams; use tokio::sync::mpsc; @@ -57,9 +59,7 @@ impl<'context> ContinuousBatchEmbeddingProcessor<'context> { .inference_parameters .enable_embeddings { - generated_embedding_tx.send(EmbeddingResult::Error( - "Embeddings are not enabled for this agent".to_owned(), - ))?; + generated_embedding_tx.send(EmbeddingResult::EmbeddingsDisabled)?; return Err(anyhow!("Embeddings are not enabled")); } @@ -84,7 +84,35 @@ impl<'context> ContinuousBatchEmbeddingProcessor<'context> { let n_batch = self.scheduler_context.inference_parameters.n_batch; let max_sequences_per_batch = self.scheduler_context.desired_slots_total; - let token_counts: Vec = tokens_lines_list + + let mut tokens_lines_list_within_batch: Vec = Vec::new(); + for input in tokens_lines_list { + if input.tokens.len() > n_batch { + #[expect( + clippy::cast_possible_truncation, + reason = "document token counts and n_batch are model-bounded and fit in u32" + )] + let details = OversizedEmbeddingDocumentDetails { + document_tokens: input.tokens.len() as u32, + n_batch: n_batch as u32, + source_document_id: input.id.clone(), + }; + + warn!( + "{:?}: skipped embedding document {:?}: {} tokens exceeds n_batch {}", + self.scheduler_context.agent_name, + input.id, + details.document_tokens, + details.n_batch, + ); + + generated_embedding_tx.send(EmbeddingResult::DocumentExceedsBatchSize(details))?; + } else { + tokens_lines_list_within_batch.push(input); + } + } + + let token_counts: Vec = tokens_lines_list_within_batch .iter() .map(|input| input.tokens.len()) .collect(); @@ -102,8 +130,10 @@ impl<'context> ContinuousBatchEmbeddingProcessor<'context> { break; } - let batch_inputs: Vec<&EmbeddingInputTokenized> = - tokens_lines_list[planned_batch].iter().collect(); + let batch_inputs: Vec<&EmbeddingInputTokenized> = tokens_lines_list_within_batch + [planned_batch] + .iter() + .collect(); for (sequence_index, input) in batch_inputs.iter().enumerate() { batch.add_sequence(&input.tokens, sequence_index as i32, true)?; diff --git a/paddler/src/agent/continuous_batch_scheduler/mod.rs b/paddler/src/agent/continuous_batch_scheduler/mod.rs index ae8b27ce..7593a94c 100644 --- a/paddler/src/agent/continuous_batch_scheduler/mod.rs +++ b/paddler/src/agent/continuous_batch_scheduler/mod.rs @@ -754,9 +754,7 @@ impl ContinuousBatchScheduler { )) => { warn!( "{:?}: refused multimodal request: image chunk has {} tokens but n_batch is {}", - self.scheduler_context.agent_name, - mismatch.image_tokens, - mismatch.n_batch, + self.scheduler_context.agent_name, mismatch.image_tokens, mismatch.n_batch, ); self.sequence_id_pool.release(sequence_id); diff --git a/paddler/src/balancer/inference_service/app_data.rs b/paddler/src/balancer/inference_service/app_data.rs index 79c44127..26e2dee3 100644 --- a/paddler/src/balancer/inference_service/app_data.rs +++ b/paddler/src/balancer/inference_service/app_data.rs @@ -2,11 +2,13 @@ use std::sync::Arc; use tokio_util::sync::CancellationToken; +use crate::balancer::agent_controller_pool::AgentControllerPool; use crate::balancer::buffered_request_manager::BufferedRequestManager; use crate::balancer::inference_service::configuration::Configuration; use crate::balancer_applicable_state_holder::BalancerApplicableStateHolder; pub struct AppData { + pub agent_controller_pool: Arc, pub balancer_applicable_state_holder: Arc, pub buffered_request_manager: Arc, pub inference_service_configuration: Configuration, diff --git a/paddler/src/balancer/inference_service/http_route/api/post_generate_embedding_batch.rs b/paddler/src/balancer/inference_service/http_route/api/post_generate_embedding_batch.rs index 3cd09da6..a2a9c58c 100644 --- a/paddler/src/balancer/inference_service/http_route/api/post_generate_embedding_batch.rs +++ b/paddler/src/balancer/inference_service/http_route/api/post_generate_embedding_batch.rs @@ -34,8 +34,6 @@ use crate::balancer::request_from_agent::request_from_agent; use crate::cancellation_token_stream_guard::CancellationTokenStreamGuard; use crate::controls_session::ControlsSession as _; -const CHARACTERS_PER_TOKEN_APPROXIMATELY: usize = 3; - #[derive(Clone)] struct EmbeddingChunkBodyTransformer; @@ -79,15 +77,20 @@ async fn respond( )); } + let agent_count = app_data.agent_controller_pool.agents.len(); + let embedding_batch_size = agent_desired_state + .inference_parameters + .embedding_batch_size; + let connection_close = CancellationToken::new(); let (chunk_tx, chunk_rx) = mpsc::unbounded_channel(); let mut chunk_tasks: JoinSet<()> = JoinSet::new(); - for batch in params.chunk_by_input_size( - agent_desired_state.inference_parameters.n_batch - * CHARACTERS_PER_TOKEN_APPROXIMATELY, - ) { + for batch in params + .into_inner() + .chunk_evenly_with_cap(agent_count, embedding_batch_size) + { let buffered_request_manager_clone = app_data.buffered_request_manager.clone(); let chunk_tx_clone = chunk_tx.clone(); let connection_close_clone = connection_close.clone(); diff --git a/paddler/src/balancer/inference_service/mod.rs b/paddler/src/balancer/inference_service/mod.rs index 2a9ec2f7..844a004a 100644 --- a/paddler/src/balancer/inference_service/mod.rs +++ b/paddler/src/balancer/inference_service/mod.rs @@ -11,6 +11,7 @@ use anyhow::Result; use async_trait::async_trait; use tokio_util::sync::CancellationToken; +use crate::balancer::agent_controller_pool::AgentControllerPool; use crate::balancer::buffered_request_manager::BufferedRequestManager; use crate::balancer::http_route as common_http_route; use crate::balancer::inference_service::app_data::AppData; @@ -22,6 +23,7 @@ use crate::create_cors_middleware::create_cors_middleware; use crate::service::Service; pub struct InferenceService { + pub agent_controller_pool: Arc, pub balancer_applicable_state_holder: Arc, pub buffered_request_manager: Arc, pub configuration: InferenceServiceConfiguration, @@ -47,6 +49,7 @@ impl Service for InferenceService { let cors_allowed_hosts_arc = Arc::new(cors_allowed_hosts); let app_data = Data::new(AppData { + agent_controller_pool: self.agent_controller_pool.clone(), balancer_applicable_state_holder: self.balancer_applicable_state_holder.clone(), buffered_request_manager: self.buffered_request_manager.clone(), inference_service_configuration: self.configuration.clone(), diff --git a/paddler_bootstrap/src/bootstrapped_balancer_handle.rs b/paddler_bootstrap/src/bootstrapped_balancer_handle.rs index ee706483..1e208648 100644 --- a/paddler_bootstrap/src/bootstrapped_balancer_handle.rs +++ b/paddler_bootstrap/src/bootstrapped_balancer_handle.rs @@ -90,6 +90,7 @@ pub async fn bootstrap_balancer( }; service_manager.add_service(InferenceService { + agent_controller_pool: agent_controller_pool.clone(), balancer_applicable_state_holder: balancer_applicable_state_holder.clone(), buffered_request_manager: buffered_request_manager.clone(), configuration: inference_service_configuration.clone(), diff --git a/paddler_client_javascript/src/schemas/InferenceParameters.ts b/paddler_client_javascript/src/schemas/InferenceParameters.ts index 6295dec7..b7c0d206 100644 --- a/paddler_client_javascript/src/schemas/InferenceParameters.ts +++ b/paddler_client_javascript/src/schemas/InferenceParameters.ts @@ -25,6 +25,7 @@ export const InferenceParametersSchema = z .object({ n_batch: z.number(), context_size: z.number(), + embedding_batch_size: z.number().int().min(1), enable_embeddings: z.boolean(), image_resize_to_fit: z.number().int().min(1), k_cache_dtype: z.enum(cacheDtypes), diff --git a/paddler_tests/src/agent_config.rs b/paddler_tests/src/agent_config.rs new file mode 100644 index 00000000..bc3cb9f9 --- /dev/null +++ b/paddler_tests/src/agent_config.rs @@ -0,0 +1,25 @@ +#[derive(Clone, Debug)] +pub struct AgentConfig { + pub name: String, + pub slot_count: i32, +} + +impl AgentConfig { + #[must_use] + pub fn single(slot_count: i32) -> Self { + Self { + name: "test-agent".to_owned(), + slot_count, + } + } + + #[must_use] + pub fn uniform(count: usize, slot_count: i32) -> Vec { + (0..count) + .map(|agent_index| Self { + name: format!("test-agent-{agent_index}"), + slot_count, + }) + .collect() + } +} diff --git a/paddler_tests/src/agents_stream_watcher.rs b/paddler_tests/src/agents_stream_watcher.rs index 5b79dcdc..5d33dd95 100644 --- a/paddler_tests/src/agents_stream_watcher.rs +++ b/paddler_tests/src/agents_stream_watcher.rs @@ -55,20 +55,34 @@ impl AgentsStreamWatcher { )) } - pub async fn wait_for_slots_ready( - &mut self, - expected_agent_count: usize, - slots_per_agent: i32, - ) -> Result<()> { + pub async fn wait_for_slots_ready(&mut self, expected_slot_counts: &[i32]) -> Result<()> { + let mut expected_sorted: Vec = expected_slot_counts.to_vec(); + expected_sorted.sort_unstable(); + let expected_agent_count = expected_sorted.len(); + let snapshot = self .until(move |snapshot| { - snapshot.agents.len() >= expected_agent_count - && snapshot.agents.iter().all(|agent| { - agent.slots_total >= slots_per_agent || !agent.issues.is_empty() - }) + if snapshot.agents.len() < expected_agent_count { + return false; + } + + let any_with_issues = snapshot.agents.iter().any(|agent| !agent.issues.is_empty()); + + if any_with_issues { + return true; + } + + let mut observed_slot_counts: Vec = snapshot + .agents + .iter() + .map(|agent| agent.slots_total) + .collect(); + observed_slot_counts.sort_unstable(); + + observed_slot_counts == expected_sorted }) .await - .context("agents did not reach the requested slot count")?; + .context("agents did not reach the requested slot counts")?; let agents_with_issues: Vec = snapshot .agents diff --git a/paddler_tests/src/collect_embedding_results.rs b/paddler_tests/src/collect_embedding_results.rs index 69ecd373..8c311940 100644 --- a/paddler_tests/src/collect_embedding_results.rs +++ b/paddler_tests/src/collect_embedding_results.rs @@ -14,8 +14,11 @@ pub async fn collect_embedding_results( mut stream: InferenceMessageStream, ) -> Result { let mut embeddings: Vec = Vec::new(); + let mut embeddings_disabled = false; let mut errors: Vec = Vec::new(); + let mut oversized_documents = Vec::new(); let mut saw_done = false; + let mut wire_errors = Vec::new(); while let Some(item) = stream.next().await { let message = item.context("embedding stream yielded an error")?; @@ -36,6 +39,14 @@ pub async fn collect_embedding_results( generated_by, }); } + InferenceResponse::Embedding(EmbeddingResult::DocumentExceedsBatchSize( + details, + )) => { + oversized_documents.push(details); + } + InferenceResponse::Embedding(EmbeddingResult::EmbeddingsDisabled) => { + embeddings_disabled = true; + } InferenceResponse::Embedding(EmbeddingResult::Error(message)) => { errors.push(message); } @@ -55,18 +66,17 @@ pub async fn collect_embedding_results( } } InferenceMessage::Error(error_envelope) => { - return Err(anyhow!( - "embedding stream returned JSON-RPC error code {} ({})", - error_envelope.error.code, - error_envelope.error.description - )); + wire_errors.push(error_envelope.error); } } } Ok(CollectedEmbeddingResults { embeddings, + embeddings_disabled, errors, + oversized_documents, saw_done, + wire_errors, }) } diff --git a/paddler_tests/src/collected_embedding_results.rs b/paddler_tests/src/collected_embedding_results.rs index 757462b2..528d6c1b 100644 --- a/paddler_tests/src/collected_embedding_results.rs +++ b/paddler_tests/src/collected_embedding_results.rs @@ -1,7 +1,13 @@ +use paddler_types::jsonrpc::Error as JsonRpcError; +use paddler_types::oversized_embedding_document_details::OversizedEmbeddingDocumentDetails; + use crate::embedding_with_producer::EmbeddingWithProducer; pub struct CollectedEmbeddingResults { pub embeddings: Vec, + pub embeddings_disabled: bool, pub errors: Vec, + pub oversized_documents: Vec, pub saw_done: bool, + pub wire_errors: Vec, } diff --git a/paddler_tests/src/in_process_cluster_params.rs b/paddler_tests/src/in_process_cluster_params.rs index 891982dd..2585fe62 100644 --- a/paddler_tests/src/in_process_cluster_params.rs +++ b/paddler_tests/src/in_process_cluster_params.rs @@ -2,31 +2,32 @@ use std::time::Duration; use paddler_types::balancer_desired_state::BalancerDesiredState; +use crate::agent_config::AgentConfig; + pub struct InProcessClusterParams { - pub agent_name: String, + pub agent: Option, pub buffered_request_timeout: Duration, pub desired_state: BalancerDesiredState, pub inference_cors_allowed_hosts: Vec, pub inference_item_timeout: Duration, pub management_cors_allowed_hosts: Vec, pub max_buffered_requests: i32, - pub slots_per_agent: i32, - pub spawn_agent: bool, pub wait_for_slots_ready: bool, } impl Default for InProcessClusterParams { fn default() -> Self { Self { - agent_name: "test-agent".to_owned(), + agent: Some(AgentConfig { + name: "test-agent".to_owned(), + slot_count: 4, + }), buffered_request_timeout: Duration::from_secs(10), desired_state: BalancerDesiredState::default(), inference_cors_allowed_hosts: Vec::new(), inference_item_timeout: Duration::from_secs(30), management_cors_allowed_hosts: Vec::new(), max_buffered_requests: 10, - slots_per_agent: 4, - spawn_agent: true, wait_for_slots_ready: true, } } diff --git a/paddler_tests/src/lib.rs b/paddler_tests/src/lib.rs index a041d814..6ce9c02a 100644 --- a/paddler_tests/src/lib.rs +++ b/paddler_tests/src/lib.rs @@ -1,3 +1,4 @@ +pub mod agent_config; pub mod agents_status; pub mod agents_stream_watcher; pub mod balancer_addresses; @@ -21,6 +22,7 @@ pub mod model_card; pub mod openai_chat_completions_client; pub mod paddler_command; pub mod parse_test_device_value; +pub mod qwen3_embedding_cluster_params; pub mod spawn_agent_subprocess; pub mod spawn_agent_subprocess_params; pub mod start_in_process_cluster; diff --git a/paddler_tests/src/qwen3_embedding_cluster_params.rs b/paddler_tests/src/qwen3_embedding_cluster_params.rs new file mode 100644 index 00000000..64307c56 --- /dev/null +++ b/paddler_tests/src/qwen3_embedding_cluster_params.rs @@ -0,0 +1,23 @@ +use std::time::Duration; + +use paddler_types::inference_parameters::InferenceParameters; + +use crate::agent_config::AgentConfig; + +pub struct Qwen3EmbeddingClusterParams { + pub agents: Vec, + pub buffered_request_timeout: Duration, + pub inference_parameters: InferenceParameters, + pub max_buffered_requests: i32, +} + +impl Default for Qwen3EmbeddingClusterParams { + fn default() -> Self { + Self { + agents: AgentConfig::uniform(1, 4), + buffered_request_timeout: Duration::from_secs(10), + inference_parameters: InferenceParameters::default(), + max_buffered_requests: 10, + } + } +} diff --git a/paddler_tests/src/start_in_process_cluster.rs b/paddler_tests/src/start_in_process_cluster.rs index 7399bad2..68dee470 100644 --- a/paddler_tests/src/start_in_process_cluster.rs +++ b/paddler_tests/src/start_in_process_cluster.rs @@ -22,15 +22,13 @@ use crate::wait_until_healthy::wait_until_healthy; pub async fn start_in_process_cluster( InProcessClusterParams { - agent_name, + agent, buffered_request_timeout, desired_state, inference_cors_allowed_hosts, inference_item_timeout, management_cors_allowed_hosts, max_buffered_requests, - slots_per_agent, - spawn_agent, wait_for_slots_ready, }: InProcessClusterParams, ) -> Result { @@ -82,15 +80,15 @@ pub async fn start_in_process_cluster( let buffered_requests_watcher = BufferedRequestsStreamWatcher::connect(&paddler_client.management()).await?; - let expected_agent_count: usize = usize::from(spawn_agent); + let expected_agent_count: usize = usize::from(agent.is_some()); let mut agent_runners: Vec = Vec::with_capacity(expected_agent_count); - if spawn_agent { + if let Some(agent_config) = agent.as_ref() { let agent_runner = AgentRunner::start(AgentRunnerParams { - agent_name: Some(agent_name), + agent_name: Some(agent_config.name.clone()), management_address: addresses.management.to_string(), cancellation_token: cancel_token.clone(), - slots: slots_per_agent, + slots: agent_config.slot_count, }); agent_runners.push(agent_runner); @@ -104,13 +102,15 @@ pub async fn start_in_process_cluster( let agent_ids: Vec = registered_snapshot .agents .iter() - .map(|agent| agent.id.clone()) + .map(|registered_agent| registered_agent.id.clone()) .collect(); - if wait_for_slots_ready && spawn_agent { - agents_watcher - .wait_for_slots_ready(expected_agent_count, slots_per_agent) - .await?; + if wait_for_slots_ready { + if let Some(agent_config) = agent.as_ref() { + agents_watcher + .wait_for_slots_ready(&[agent_config.slot_count]) + .await?; + } } Ok(ClusterHandle::new(ClusterHandleParams { diff --git a/paddler_tests/src/start_in_process_cluster_with_deepseek_r1_distill_llama_8b.rs b/paddler_tests/src/start_in_process_cluster_with_deepseek_r1_distill_llama_8b.rs index 7d010c39..f783b62b 100644 --- a/paddler_tests/src/start_in_process_cluster_with_deepseek_r1_distill_llama_8b.rs +++ b/paddler_tests/src/start_in_process_cluster_with_deepseek_r1_distill_llama_8b.rs @@ -2,6 +2,7 @@ use anyhow::Result; use paddler_types::agent_desired_model::AgentDesiredModel; use paddler_types::balancer_desired_state::BalancerDesiredState; +use crate::agent_config::AgentConfig; use crate::cluster_handle::ClusterHandle; use crate::current_test_device::current_test_device; use crate::in_process_cluster_params::InProcessClusterParams; @@ -10,7 +11,7 @@ use crate::model_card::deepseek_r1_distill_llama_8b::deepseek_r1_distill_llama_8 use crate::start_in_process_cluster::start_in_process_cluster; pub async fn start_in_process_cluster_with_deepseek_r1_distill_llama_8b( - slots_per_agent: i32, + agent: AgentConfig, ) -> Result { let device = current_test_device()?; @@ -22,7 +23,7 @@ pub async fn start_in_process_cluster_with_deepseek_r1_distill_llama_8b( } = deepseek_r1_distill_llama_8b(); start_in_process_cluster(InProcessClusterParams { - slots_per_agent, + agent: Some(agent), desired_state: BalancerDesiredState { chat_template_override: None, inference_parameters: device.inference_parameters_for_full_offload(gpu_layer_count), diff --git a/paddler_tests/src/start_in_process_cluster_with_gemma_4.rs b/paddler_tests/src/start_in_process_cluster_with_gemma_4.rs index 1b24cc44..bc2762d5 100644 --- a/paddler_tests/src/start_in_process_cluster_with_gemma_4.rs +++ b/paddler_tests/src/start_in_process_cluster_with_gemma_4.rs @@ -2,6 +2,7 @@ use anyhow::Result; use paddler_types::agent_desired_model::AgentDesiredModel; use paddler_types::balancer_desired_state::BalancerDesiredState; +use crate::agent_config::AgentConfig; use crate::cluster_handle::ClusterHandle; use crate::current_test_device::current_test_device; use crate::in_process_cluster_params::InProcessClusterParams; @@ -9,7 +10,7 @@ use crate::model_card::ModelCard; use crate::model_card::gemma_4_e4b_it::gemma_4_e4b_it; use crate::start_in_process_cluster::start_in_process_cluster; -pub async fn start_in_process_cluster_with_gemma_4(slots_per_agent: i32) -> Result { +pub async fn start_in_process_cluster_with_gemma_4(agent: AgentConfig) -> Result { let device = current_test_device()?; device.require_available()?; @@ -20,7 +21,7 @@ pub async fn start_in_process_cluster_with_gemma_4(slots_per_agent: i32) -> Resu } = gemma_4_e4b_it(); start_in_process_cluster(InProcessClusterParams { - slots_per_agent, + agent: Some(agent), desired_state: BalancerDesiredState { chat_template_override: None, inference_parameters: device.inference_parameters_for_full_offload(gpu_layer_count), diff --git a/paddler_tests/src/start_in_process_cluster_with_gemma_4_and_mmproj.rs b/paddler_tests/src/start_in_process_cluster_with_gemma_4_and_mmproj.rs index 41c37ab1..f4297d49 100644 --- a/paddler_tests/src/start_in_process_cluster_with_gemma_4_and_mmproj.rs +++ b/paddler_tests/src/start_in_process_cluster_with_gemma_4_and_mmproj.rs @@ -2,6 +2,7 @@ use anyhow::Result; use paddler_types::agent_desired_model::AgentDesiredModel; use paddler_types::balancer_desired_state::BalancerDesiredState; +use crate::agent_config::AgentConfig; use crate::cluster_handle::ClusterHandle; use crate::current_test_device::current_test_device; use crate::in_process_cluster_params::InProcessClusterParams; @@ -11,7 +12,7 @@ use crate::model_card::gemma_4_e4b_it_mmproj::gemma_4_e4b_it_mmproj; use crate::start_in_process_cluster::start_in_process_cluster; pub async fn start_in_process_cluster_with_gemma_4_and_mmproj( - slots_per_agent: i32, + agent: AgentConfig, ) -> Result { let device = current_test_device()?; @@ -27,7 +28,7 @@ pub async fn start_in_process_cluster_with_gemma_4_and_mmproj( } = gemma_4_e4b_it_mmproj(); start_in_process_cluster(InProcessClusterParams { - slots_per_agent, + agent: Some(agent), desired_state: BalancerDesiredState { chat_template_override: None, inference_parameters: device.inference_parameters_for_full_offload(gpu_layer_count), diff --git a/paddler_tests/src/start_in_process_cluster_with_glm_4_7_flash.rs b/paddler_tests/src/start_in_process_cluster_with_glm_4_7_flash.rs index 24ddbd7a..7d055561 100644 --- a/paddler_tests/src/start_in_process_cluster_with_glm_4_7_flash.rs +++ b/paddler_tests/src/start_in_process_cluster_with_glm_4_7_flash.rs @@ -2,6 +2,7 @@ use anyhow::Result; use paddler_types::agent_desired_model::AgentDesiredModel; use paddler_types::balancer_desired_state::BalancerDesiredState; +use crate::agent_config::AgentConfig; use crate::cluster_handle::ClusterHandle; use crate::current_test_device::current_test_device; use crate::in_process_cluster_params::InProcessClusterParams; @@ -10,7 +11,7 @@ use crate::model_card::glm_4_7_flash::glm_4_7_flash; use crate::start_in_process_cluster::start_in_process_cluster; pub async fn start_in_process_cluster_with_glm_4_7_flash( - slots_per_agent: i32, + agent: AgentConfig, ) -> Result { let device = current_test_device()?; @@ -22,7 +23,7 @@ pub async fn start_in_process_cluster_with_glm_4_7_flash( } = glm_4_7_flash(); start_in_process_cluster(InProcessClusterParams { - slots_per_agent, + agent: Some(agent), desired_state: BalancerDesiredState { chat_template_override: None, inference_parameters: device.inference_parameters_for_full_offload(gpu_layer_count), diff --git a/paddler_tests/src/start_in_process_cluster_with_ministral_3.rs b/paddler_tests/src/start_in_process_cluster_with_ministral_3.rs index 7a2eea0b..a9cd6b7c 100644 --- a/paddler_tests/src/start_in_process_cluster_with_ministral_3.rs +++ b/paddler_tests/src/start_in_process_cluster_with_ministral_3.rs @@ -2,6 +2,7 @@ use anyhow::Result; use paddler_types::agent_desired_model::AgentDesiredModel; use paddler_types::balancer_desired_state::BalancerDesiredState; +use crate::agent_config::AgentConfig; use crate::cluster_handle::ClusterHandle; use crate::current_test_device::current_test_device; use crate::in_process_cluster_params::InProcessClusterParams; @@ -10,7 +11,7 @@ use crate::model_card::ministral_3_14b_reasoning::ministral_3_14b_reasoning; use crate::start_in_process_cluster::start_in_process_cluster; pub async fn start_in_process_cluster_with_ministral_3( - slots_per_agent: i32, + agent: AgentConfig, ) -> Result { let device = current_test_device()?; @@ -22,7 +23,7 @@ pub async fn start_in_process_cluster_with_ministral_3( } = ministral_3_14b_reasoning(); start_in_process_cluster(InProcessClusterParams { - slots_per_agent, + agent: Some(agent), desired_state: BalancerDesiredState { chat_template_override: None, inference_parameters: device.inference_parameters_for_full_offload(gpu_layer_count), diff --git a/paddler_tests/src/start_in_process_cluster_with_ministral_3_and_mmproj.rs b/paddler_tests/src/start_in_process_cluster_with_ministral_3_and_mmproj.rs index bdfd108a..515ac248 100644 --- a/paddler_tests/src/start_in_process_cluster_with_ministral_3_and_mmproj.rs +++ b/paddler_tests/src/start_in_process_cluster_with_ministral_3_and_mmproj.rs @@ -2,6 +2,7 @@ use anyhow::Result; use paddler_types::agent_desired_model::AgentDesiredModel; use paddler_types::balancer_desired_state::BalancerDesiredState; +use crate::agent_config::AgentConfig; use crate::cluster_handle::ClusterHandle; use crate::current_test_device::current_test_device; use crate::in_process_cluster_params::InProcessClusterParams; @@ -11,7 +12,7 @@ use crate::model_card::ministral_3_14b_reasoning_mmproj::ministral_3_14b_reasoni use crate::start_in_process_cluster::start_in_process_cluster; pub async fn start_in_process_cluster_with_ministral_3_and_mmproj( - slots_per_agent: i32, + agent: AgentConfig, ) -> Result { let device = current_test_device()?; @@ -27,7 +28,7 @@ pub async fn start_in_process_cluster_with_ministral_3_and_mmproj( } = ministral_3_14b_reasoning_mmproj(); start_in_process_cluster(InProcessClusterParams { - slots_per_agent, + agent: Some(agent), desired_state: BalancerDesiredState { chat_template_override: None, inference_parameters: device.inference_parameters_for_full_offload(gpu_layer_count), diff --git a/paddler_tests/src/start_in_process_cluster_with_qwen2_5_vl.rs b/paddler_tests/src/start_in_process_cluster_with_qwen2_5_vl.rs index ddb6b8a7..cb5dc0a9 100644 --- a/paddler_tests/src/start_in_process_cluster_with_qwen2_5_vl.rs +++ b/paddler_tests/src/start_in_process_cluster_with_qwen2_5_vl.rs @@ -2,6 +2,7 @@ use anyhow::Result; use paddler_types::agent_desired_model::AgentDesiredModel; use paddler_types::balancer_desired_state::BalancerDesiredState; +use crate::agent_config::AgentConfig; use crate::cluster_handle::ClusterHandle; use crate::current_test_device::current_test_device; use crate::in_process_cluster_params::InProcessClusterParams; @@ -10,9 +11,7 @@ use crate::model_card::qwen2_5_vl_3b::qwen2_5_vl_3b; use crate::model_card::qwen2_5_vl_3b_mmproj::qwen2_5_vl_3b_mmproj; use crate::start_in_process_cluster::start_in_process_cluster; -pub async fn start_in_process_cluster_with_qwen2_5_vl( - slots_per_agent: i32, -) -> Result { +pub async fn start_in_process_cluster_with_qwen2_5_vl(agent: AgentConfig) -> Result { let device = current_test_device()?; device.require_available()?; @@ -27,7 +26,7 @@ pub async fn start_in_process_cluster_with_qwen2_5_vl( } = qwen2_5_vl_3b_mmproj(); start_in_process_cluster(InProcessClusterParams { - slots_per_agent, + agent: Some(agent), desired_state: BalancerDesiredState { chat_template_override: None, inference_parameters: device.inference_parameters_for_full_offload(gpu_layer_count), diff --git a/paddler_tests/src/start_in_process_cluster_with_qwen3.rs b/paddler_tests/src/start_in_process_cluster_with_qwen3.rs index b42c2d2d..befceb8e 100644 --- a/paddler_tests/src/start_in_process_cluster_with_qwen3.rs +++ b/paddler_tests/src/start_in_process_cluster_with_qwen3.rs @@ -2,6 +2,7 @@ use anyhow::Result; use paddler_types::agent_desired_model::AgentDesiredModel; use paddler_types::balancer_desired_state::BalancerDesiredState; +use crate::agent_config::AgentConfig; use crate::cluster_handle::ClusterHandle; use crate::current_test_device::current_test_device; use crate::in_process_cluster_params::InProcessClusterParams; @@ -9,7 +10,7 @@ use crate::model_card::ModelCard; use crate::model_card::qwen3_0_6b::qwen3_0_6b; use crate::start_in_process_cluster::start_in_process_cluster; -pub async fn start_in_process_cluster_with_qwen3(slots_per_agent: i32) -> Result { +pub async fn start_in_process_cluster_with_qwen3(agent: AgentConfig) -> Result { let device = current_test_device()?; device.require_available()?; @@ -20,7 +21,7 @@ pub async fn start_in_process_cluster_with_qwen3(slots_per_agent: i32) -> Result } = qwen3_0_6b(); start_in_process_cluster(InProcessClusterParams { - slots_per_agent, + agent: Some(agent), desired_state: BalancerDesiredState { chat_template_override: None, inference_parameters: device.inference_parameters_for_full_offload(gpu_layer_count), diff --git a/paddler_tests/src/start_in_process_cluster_with_qwen3_5.rs b/paddler_tests/src/start_in_process_cluster_with_qwen3_5.rs index 1f75faf5..3d9189bd 100644 --- a/paddler_tests/src/start_in_process_cluster_with_qwen3_5.rs +++ b/paddler_tests/src/start_in_process_cluster_with_qwen3_5.rs @@ -2,6 +2,7 @@ use anyhow::Result; use paddler_types::agent_desired_model::AgentDesiredModel; use paddler_types::balancer_desired_state::BalancerDesiredState; +use crate::agent_config::AgentConfig; use crate::cluster_handle::ClusterHandle; use crate::current_test_device::current_test_device; use crate::in_process_cluster_params::InProcessClusterParams; @@ -11,7 +12,7 @@ use crate::model_card::qwen3_5_0_8b_mmproj::qwen3_5_0_8b_mmproj; use crate::start_in_process_cluster::start_in_process_cluster; pub async fn start_in_process_cluster_with_qwen3_5( - slots_per_agent: i32, + agent: AgentConfig, with_mmproj: bool, ) -> Result { let device = current_test_device()?; @@ -35,7 +36,7 @@ pub async fn start_in_process_cluster_with_qwen3_5( }; start_in_process_cluster(InProcessClusterParams { - slots_per_agent, + agent: Some(agent), desired_state: BalancerDesiredState { chat_template_override: None, inference_parameters: device.inference_parameters_for_full_offload(gpu_layer_count), diff --git a/paddler_tests/src/start_in_process_cluster_with_qwen3_6.rs b/paddler_tests/src/start_in_process_cluster_with_qwen3_6.rs index 560d9c83..7f6e765b 100644 --- a/paddler_tests/src/start_in_process_cluster_with_qwen3_6.rs +++ b/paddler_tests/src/start_in_process_cluster_with_qwen3_6.rs @@ -2,6 +2,7 @@ use anyhow::Result; use paddler_types::agent_desired_model::AgentDesiredModel; use paddler_types::balancer_desired_state::BalancerDesiredState; +use crate::agent_config::AgentConfig; use crate::cluster_handle::ClusterHandle; use crate::current_test_device::current_test_device; use crate::in_process_cluster_params::InProcessClusterParams; @@ -9,7 +10,7 @@ use crate::model_card::ModelCard; use crate::model_card::qwen3_6_35b_a3b::qwen3_6_35b_a3b; use crate::start_in_process_cluster::start_in_process_cluster; -pub async fn start_in_process_cluster_with_qwen3_6(slots_per_agent: i32) -> Result { +pub async fn start_in_process_cluster_with_qwen3_6(agent: AgentConfig) -> Result { let device = current_test_device()?; device.require_available()?; @@ -20,7 +21,7 @@ pub async fn start_in_process_cluster_with_qwen3_6(slots_per_agent: i32) -> Resu } = qwen3_6_35b_a3b(); start_in_process_cluster(InProcessClusterParams { - slots_per_agent, + agent: Some(agent), desired_state: BalancerDesiredState { chat_template_override: None, inference_parameters: device.inference_parameters_for_full_offload(gpu_layer_count), diff --git a/paddler_tests/src/start_in_process_cluster_with_qwen3_6_and_mmproj.rs b/paddler_tests/src/start_in_process_cluster_with_qwen3_6_and_mmproj.rs index add0a7d1..2d5c5dad 100644 --- a/paddler_tests/src/start_in_process_cluster_with_qwen3_6_and_mmproj.rs +++ b/paddler_tests/src/start_in_process_cluster_with_qwen3_6_and_mmproj.rs @@ -2,6 +2,7 @@ use anyhow::Result; use paddler_types::agent_desired_model::AgentDesiredModel; use paddler_types::balancer_desired_state::BalancerDesiredState; +use crate::agent_config::AgentConfig; use crate::cluster_handle::ClusterHandle; use crate::current_test_device::current_test_device; use crate::in_process_cluster_params::InProcessClusterParams; @@ -11,7 +12,7 @@ use crate::model_card::qwen3_6_35b_a3b_mmproj::qwen3_6_35b_a3b_mmproj; use crate::start_in_process_cluster::start_in_process_cluster; pub async fn start_in_process_cluster_with_qwen3_6_and_mmproj( - slots_per_agent: i32, + agent: AgentConfig, ) -> Result { let device = current_test_device()?; @@ -27,7 +28,7 @@ pub async fn start_in_process_cluster_with_qwen3_6_and_mmproj( } = qwen3_6_35b_a3b_mmproj(); start_in_process_cluster(InProcessClusterParams { - slots_per_agent, + agent: Some(agent), desired_state: BalancerDesiredState { chat_template_override: None, inference_parameters: device.inference_parameters_for_full_offload(gpu_layer_count), diff --git a/paddler_tests/src/start_in_process_cluster_with_smolvlm2.rs b/paddler_tests/src/start_in_process_cluster_with_smolvlm2.rs index 96d86d4c..b92fa31c 100644 --- a/paddler_tests/src/start_in_process_cluster_with_smolvlm2.rs +++ b/paddler_tests/src/start_in_process_cluster_with_smolvlm2.rs @@ -2,6 +2,7 @@ use anyhow::Result; use paddler_types::agent_desired_model::AgentDesiredModel; use paddler_types::balancer_desired_state::BalancerDesiredState; +use crate::agent_config::AgentConfig; use crate::cluster_handle::ClusterHandle; use crate::current_test_device::current_test_device; use crate::in_process_cluster_params::InProcessClusterParams; @@ -10,7 +11,7 @@ use crate::model_card::smolvlm2_256m::smolvlm2_256m; use crate::model_card::smolvlm2_256m_mmproj::smolvlm2_256m_mmproj; use crate::start_in_process_cluster::start_in_process_cluster; -pub async fn start_in_process_cluster_with_smolvlm2(slots_per_agent: i32) -> Result { +pub async fn start_in_process_cluster_with_smolvlm2(agent: AgentConfig) -> Result { let device = current_test_device()?; device.require_available()?; @@ -25,7 +26,7 @@ pub async fn start_in_process_cluster_with_smolvlm2(slots_per_agent: i32) -> Res } = smolvlm2_256m_mmproj(); start_in_process_cluster(InProcessClusterParams { - slots_per_agent, + agent: Some(agent), desired_state: BalancerDesiredState { chat_template_override: None, inference_parameters: device.inference_parameters_for_full_offload(gpu_layer_count), diff --git a/paddler_tests/src/start_in_process_cluster_with_smolvlm2_and_n_batch.rs b/paddler_tests/src/start_in_process_cluster_with_smolvlm2_and_n_batch.rs index fa4fd3f0..b6bbd67a 100644 --- a/paddler_tests/src/start_in_process_cluster_with_smolvlm2_and_n_batch.rs +++ b/paddler_tests/src/start_in_process_cluster_with_smolvlm2_and_n_batch.rs @@ -2,6 +2,7 @@ use anyhow::Result; use paddler_types::agent_desired_model::AgentDesiredModel; use paddler_types::balancer_desired_state::BalancerDesiredState; +use crate::agent_config::AgentConfig; use crate::cluster_handle::ClusterHandle; use crate::current_test_device::current_test_device; use crate::in_process_cluster_params::InProcessClusterParams; @@ -11,7 +12,7 @@ use crate::model_card::smolvlm2_256m_mmproj::smolvlm2_256m_mmproj; use crate::start_in_process_cluster::start_in_process_cluster; pub async fn start_in_process_cluster_with_smolvlm2_and_n_batch( - slots_per_agent: i32, + agent: AgentConfig, n_batch: usize, ) -> Result { let device = current_test_device()?; @@ -31,7 +32,7 @@ pub async fn start_in_process_cluster_with_smolvlm2_and_n_batch( inference_parameters.n_batch = n_batch; start_in_process_cluster(InProcessClusterParams { - slots_per_agent, + agent: Some(agent), desired_state: BalancerDesiredState { chat_template_override: None, inference_parameters, diff --git a/paddler_tests/src/start_in_process_embedding_cluster.rs b/paddler_tests/src/start_in_process_embedding_cluster.rs index 800e8237..827dbf8c 100644 --- a/paddler_tests/src/start_in_process_embedding_cluster.rs +++ b/paddler_tests/src/start_in_process_embedding_cluster.rs @@ -3,6 +3,7 @@ use paddler_types::agent_desired_model::AgentDesiredModel; use paddler_types::balancer_desired_state::BalancerDesiredState; use paddler_types::inference_parameters::InferenceParameters; +use crate::agent_config::AgentConfig; use crate::cluster_handle::ClusterHandle; use crate::in_process_cluster_params::InProcessClusterParams; use crate::model_card::ModelCard; @@ -11,12 +12,12 @@ use crate::start_in_process_cluster::start_in_process_cluster; pub async fn start_in_process_embedding_cluster( inference_parameters: InferenceParameters, - slots_per_agent: i32, + agent: AgentConfig, ) -> Result { let ModelCard { reference, .. } = qwen3_embedding_0_6b(); start_in_process_cluster(InProcessClusterParams { - slots_per_agent, + agent: Some(agent), desired_state: BalancerDesiredState { chat_template_override: None, inference_parameters, diff --git a/paddler_tests/src/start_subprocess_cluster.rs b/paddler_tests/src/start_subprocess_cluster.rs index 2677d9dc..cc21b568 100644 --- a/paddler_tests/src/start_subprocess_cluster.rs +++ b/paddler_tests/src/start_subprocess_cluster.rs @@ -18,15 +18,13 @@ use crate::wait_until_healthy::wait_until_healthy; pub async fn start_subprocess_cluster( SubprocessClusterParams { - agent_count, - agent_name_prefix, + agents, buffered_request_timeout, desired_state, inference_cors_allowed_hosts, inference_item_timeout, management_cors_allowed_hosts, max_buffered_requests, - slots_per_agent, state_database_url, wait_for_slots_ready, }: SubprocessClusterParams, @@ -92,19 +90,18 @@ pub async fn start_subprocess_cluster( let buffered_requests_watcher = BufferedRequestsStreamWatcher::connect(&paddler_client.management()).await?; - let mut agent_children: Vec = Vec::with_capacity(agent_count); - - for agent_index in 0..agent_count { - let agent_name = format!("{agent_name_prefix}-{agent_index}"); + let expected_agent_count = agents.len(); + let mut agent_children: Vec = Vec::with_capacity(expected_agent_count); + for agent in &agents { let agent_child = paddler_command() .arg("agent") .arg("--management-addr") .arg(addresses.management.to_string()) .arg("--name") - .arg(agent_name) + .arg(&agent.name) .arg("--slots") - .arg(slots_per_agent.to_string()) + .arg(agent.slot_count.to_string()) .stdout(Stdio::null()) .stderr(Stdio::null()) .spawn() @@ -114,19 +111,21 @@ pub async fn start_subprocess_cluster( } let registered_snapshot = agents_watcher - .until(move |snapshot| snapshot.agents.len() >= agent_count) + .until(move |snapshot| snapshot.agents.len() >= expected_agent_count) .await .context("not all subprocess agents registered")?; let agent_ids: Vec = registered_snapshot .agents .iter() - .map(|agent| agent.id.clone()) + .map(|registered_agent| registered_agent.id.clone()) .collect(); if wait_for_slots_ready { + let expected_slot_counts: Vec = agents.iter().map(|agent| agent.slot_count).collect(); + agents_watcher - .wait_for_slots_ready(agent_count, slots_per_agent) + .wait_for_slots_ready(&expected_slot_counts) .await?; } diff --git a/paddler_tests/src/start_subprocess_cluster_with_qwen2_5_vl.rs b/paddler_tests/src/start_subprocess_cluster_with_qwen2_5_vl.rs index e5657500..c898cc8e 100644 --- a/paddler_tests/src/start_subprocess_cluster_with_qwen2_5_vl.rs +++ b/paddler_tests/src/start_subprocess_cluster_with_qwen2_5_vl.rs @@ -2,6 +2,7 @@ use anyhow::Result; use paddler_types::agent_desired_model::AgentDesiredModel; use paddler_types::balancer_desired_state::BalancerDesiredState; +use crate::agent_config::AgentConfig; use crate::cluster_handle::ClusterHandle; use crate::current_test_device::current_test_device; use crate::model_card::ModelCard; @@ -11,8 +12,7 @@ use crate::start_subprocess_cluster::start_subprocess_cluster; use crate::subprocess_cluster_params::SubprocessClusterParams; pub async fn start_subprocess_cluster_with_qwen2_5_vl( - slots_per_agent: i32, - agent_count: usize, + agents: Vec, ) -> Result { let device = current_test_device()?; @@ -28,8 +28,7 @@ pub async fn start_subprocess_cluster_with_qwen2_5_vl( } = qwen2_5_vl_3b_mmproj(); start_subprocess_cluster(SubprocessClusterParams { - agent_count, - slots_per_agent, + agents, desired_state: Some(BalancerDesiredState { chat_template_override: None, inference_parameters: device.inference_parameters_for_full_offload(gpu_layer_count), diff --git a/paddler_tests/src/start_subprocess_cluster_with_qwen3.rs b/paddler_tests/src/start_subprocess_cluster_with_qwen3.rs index 3f623f35..36db2eff 100644 --- a/paddler_tests/src/start_subprocess_cluster_with_qwen3.rs +++ b/paddler_tests/src/start_subprocess_cluster_with_qwen3.rs @@ -2,6 +2,7 @@ use anyhow::Result; use paddler_types::agent_desired_model::AgentDesiredModel; use paddler_types::balancer_desired_state::BalancerDesiredState; +use crate::agent_config::AgentConfig; use crate::cluster_handle::ClusterHandle; use crate::current_test_device::current_test_device; use crate::model_card::ModelCard; @@ -10,8 +11,7 @@ use crate::start_subprocess_cluster::start_subprocess_cluster; use crate::subprocess_cluster_params::SubprocessClusterParams; pub async fn start_subprocess_cluster_with_qwen3( - slots_per_agent: i32, - agent_count: usize, + agents: Vec, ) -> Result { let device = current_test_device()?; @@ -23,8 +23,7 @@ pub async fn start_subprocess_cluster_with_qwen3( } = qwen3_0_6b(); start_subprocess_cluster(SubprocessClusterParams { - agent_count, - slots_per_agent, + agents, desired_state: Some(BalancerDesiredState { chat_template_override: None, inference_parameters: device.inference_parameters_for_full_offload(gpu_layer_count), diff --git a/paddler_tests/src/start_subprocess_cluster_with_qwen3_embedding.rs b/paddler_tests/src/start_subprocess_cluster_with_qwen3_embedding.rs index e7852b04..af4a4fc7 100644 --- a/paddler_tests/src/start_subprocess_cluster_with_qwen3_embedding.rs +++ b/paddler_tests/src/start_subprocess_cluster_with_qwen3_embedding.rs @@ -1,24 +1,27 @@ use anyhow::Result; use paddler_types::agent_desired_model::AgentDesiredModel; use paddler_types::balancer_desired_state::BalancerDesiredState; -use paddler_types::inference_parameters::InferenceParameters; use crate::cluster_handle::ClusterHandle; use crate::model_card::ModelCard; use crate::model_card::qwen3_embedding_0_6b::qwen3_embedding_0_6b; +use crate::qwen3_embedding_cluster_params::Qwen3EmbeddingClusterParams; use crate::start_subprocess_cluster::start_subprocess_cluster; use crate::subprocess_cluster_params::SubprocessClusterParams; pub async fn start_subprocess_cluster_with_qwen3_embedding( - inference_parameters: InferenceParameters, - slots_per_agent: i32, - agent_count: usize, + Qwen3EmbeddingClusterParams { + agents, + buffered_request_timeout, + inference_parameters, + max_buffered_requests, + }: Qwen3EmbeddingClusterParams, ) -> Result { let ModelCard { reference, .. } = qwen3_embedding_0_6b(); start_subprocess_cluster(SubprocessClusterParams { - agent_count, - slots_per_agent, + agents, + buffered_request_timeout, desired_state: Some(BalancerDesiredState { chat_template_override: None, inference_parameters, @@ -26,6 +29,7 @@ pub async fn start_subprocess_cluster_with_qwen3_embedding( multimodal_projection: AgentDesiredModel::None, use_chat_template_override: false, }), + max_buffered_requests, wait_for_slots_ready: true, ..SubprocessClusterParams::default() }) diff --git a/paddler_tests/src/start_subprocess_cluster_with_smolvlm2.rs b/paddler_tests/src/start_subprocess_cluster_with_smolvlm2.rs index 8c02df63..88d324ef 100644 --- a/paddler_tests/src/start_subprocess_cluster_with_smolvlm2.rs +++ b/paddler_tests/src/start_subprocess_cluster_with_smolvlm2.rs @@ -2,6 +2,7 @@ use anyhow::Result; use paddler_types::agent_desired_model::AgentDesiredModel; use paddler_types::balancer_desired_state::BalancerDesiredState; +use crate::agent_config::AgentConfig; use crate::cluster_handle::ClusterHandle; use crate::current_test_device::current_test_device; use crate::model_card::ModelCard; @@ -11,8 +12,7 @@ use crate::start_subprocess_cluster::start_subprocess_cluster; use crate::subprocess_cluster_params::SubprocessClusterParams; pub async fn start_subprocess_cluster_with_smolvlm2( - slots_per_agent: i32, - agent_count: usize, + agents: Vec, ) -> Result { let device = current_test_device()?; @@ -28,8 +28,7 @@ pub async fn start_subprocess_cluster_with_smolvlm2( } = smolvlm2_256m_mmproj(); start_subprocess_cluster(SubprocessClusterParams { - agent_count, - slots_per_agent, + agents, desired_state: Some(BalancerDesiredState { chat_template_override: None, inference_parameters: device.inference_parameters_for_full_offload(gpu_layer_count), diff --git a/paddler_tests/src/subprocess_cluster_params.rs b/paddler_tests/src/subprocess_cluster_params.rs index b33bdbbb..1a06764e 100644 --- a/paddler_tests/src/subprocess_cluster_params.rs +++ b/paddler_tests/src/subprocess_cluster_params.rs @@ -2,16 +2,16 @@ use std::time::Duration; use paddler_types::balancer_desired_state::BalancerDesiredState; +use crate::agent_config::AgentConfig; + pub struct SubprocessClusterParams { - pub agent_count: usize, - pub agent_name_prefix: String, + pub agents: Vec, pub buffered_request_timeout: Duration, pub desired_state: Option, pub inference_cors_allowed_hosts: Vec, pub inference_item_timeout: Duration, pub management_cors_allowed_hosts: Vec, pub max_buffered_requests: i32, - pub slots_per_agent: i32, pub state_database_url: String, pub wait_for_slots_ready: bool, } @@ -19,15 +19,13 @@ pub struct SubprocessClusterParams { impl Default for SubprocessClusterParams { fn default() -> Self { Self { - agent_count: 1, - agent_name_prefix: "test-agent".to_owned(), + agents: AgentConfig::uniform(1, 4), buffered_request_timeout: Duration::from_secs(10), desired_state: Some(BalancerDesiredState::default()), inference_cors_allowed_hosts: Vec::new(), inference_item_timeout: Duration::from_secs(30), management_cors_allowed_hosts: Vec::new(), max_buffered_requests: 10, - slots_per_agent: 4, state_database_url: "memory://".to_owned(), wait_for_slots_ready: true, } diff --git a/paddler_tests/tests/agent_chunks_embedding_batch_larger_than_slot_count.rs b/paddler_tests/tests/agent_chunks_embedding_batch_larger_than_slot_count.rs index be50829a..aed646c8 100644 --- a/paddler_tests/tests/agent_chunks_embedding_batch_larger_than_slot_count.rs +++ b/paddler_tests/tests/agent_chunks_embedding_batch_larger_than_slot_count.rs @@ -3,6 +3,7 @@ use std::collections::BTreeSet; use anyhow::Result; +use paddler_tests::agent_config::AgentConfig; use paddler_tests::collect_embedding_results::collect_embedding_results; use paddler_tests::inference_http_client::InferenceHttpClient; use paddler_tests::start_in_process_embedding_cluster::start_in_process_embedding_cluster; @@ -20,7 +21,7 @@ async fn agent_chunks_embedding_batch_larger_than_slot_count() -> Result<()> { enable_embeddings: true, ..InferenceParameters::default() }, - 4, + AgentConfig::single(4), ) .await?; diff --git a/paddler_tests/tests/agent_completes_generation_with_adequate_n_batch.rs b/paddler_tests/tests/agent_completes_generation_with_adequate_n_batch.rs index 6c1babac..abfddfe9 100644 --- a/paddler_tests/tests/agent_completes_generation_with_adequate_n_batch.rs +++ b/paddler_tests/tests/agent_completes_generation_with_adequate_n_batch.rs @@ -6,6 +6,7 @@ use anyhow::Context as _; use anyhow::Result; use base64::Engine as _; use base64::engine::general_purpose::STANDARD as BASE64_STANDARD; +use paddler_tests::agent_config::AgentConfig; use paddler_tests::collect_generated_tokens::collect_generated_tokens; use paddler_tests::inference_http_client::InferenceHttpClient; use paddler_tests::start_in_process_cluster_with_smolvlm2::start_in_process_cluster_with_smolvlm2; @@ -18,10 +19,7 @@ use paddler_types::request_params::continue_from_conversation_history_params::Co use reqwest::Client; fn load_fixture_as_data_uri(fixture_name: &str, mime_type: &str) -> Result { - let fixture_path = format!( - "{}/../fixtures/{fixture_name}", - env!("CARGO_MANIFEST_DIR") - ); + let fixture_path = format!("{}/../fixtures/{fixture_name}", env!("CARGO_MANIFEST_DIR")); let bytes = fs::read(&fixture_path) .with_context(|| format!("failed to read test fixture {fixture_path}"))?; let encoded = BASE64_STANDARD.encode(&bytes); @@ -83,7 +81,7 @@ async fn drive_normal_image_fixture( #[serial_test::file_serial(model_load, path => "../target/model_load.lock")] #[tokio::test(flavor = "multi_thread")] async fn agent_completes_generation_with_adequate_n_batch() -> Result<()> { - let cluster = start_in_process_cluster_with_smolvlm2(1).await?; + let cluster = start_in_process_cluster_with_smolvlm2(AgentConfig::single(1)).await?; let inference_client = InferenceHttpClient::new(Client::new(), cluster.addresses.inference_base_url()?); diff --git a/paddler_tests/tests/agent_conversation_accepts_empty_tools_list.rs b/paddler_tests/tests/agent_conversation_accepts_empty_tools_list.rs index 7ab70d80..fe4ebffc 100644 --- a/paddler_tests/tests/agent_conversation_accepts_empty_tools_list.rs +++ b/paddler_tests/tests/agent_conversation_accepts_empty_tools_list.rs @@ -4,6 +4,7 @@ ))] use anyhow::Result; +use paddler_tests::agent_config::AgentConfig; use paddler_tests::collect_generated_tokens::collect_generated_tokens; use paddler_tests::inference_http_client::InferenceHttpClient; use paddler_tests::start_subprocess_cluster_with_qwen3::start_subprocess_cluster_with_qwen3; @@ -16,7 +17,7 @@ use reqwest::Client; #[serial_test::file_serial(model_load, path => "../target/model_load.lock")] #[tokio::test(flavor = "multi_thread")] async fn agent_conversation_accepts_empty_tools_list() -> Result<()> { - let cluster = start_subprocess_cluster_with_qwen3(2, 1).await?; + let cluster = start_subprocess_cluster_with_qwen3(AgentConfig::uniform(1, 2)).await?; let inference_client = InferenceHttpClient::new(Client::new(), cluster.addresses.inference_base_url()?); diff --git a/paddler_tests/tests/agent_conversation_history_respects_max_tokens.rs b/paddler_tests/tests/agent_conversation_history_respects_max_tokens.rs index f663d22a..a77054b9 100644 --- a/paddler_tests/tests/agent_conversation_history_respects_max_tokens.rs +++ b/paddler_tests/tests/agent_conversation_history_respects_max_tokens.rs @@ -4,6 +4,7 @@ ))] use anyhow::Result; +use paddler_tests::agent_config::AgentConfig; use paddler_tests::collect_generated_tokens::collect_generated_tokens; use paddler_tests::inference_http_client::InferenceHttpClient; use paddler_tests::start_subprocess_cluster_with_qwen3::start_subprocess_cluster_with_qwen3; @@ -16,7 +17,7 @@ use reqwest::Client; #[serial_test::file_serial(model_load, path => "../target/model_load.lock")] #[tokio::test(flavor = "multi_thread")] async fn agent_conversation_history_respects_max_tokens() -> Result<()> { - let cluster = start_subprocess_cluster_with_qwen3(2, 1).await?; + let cluster = start_subprocess_cluster_with_qwen3(AgentConfig::uniform(1, 2)).await?; let inference_client = InferenceHttpClient::new(Client::new(), cluster.addresses.inference_base_url()?); diff --git a/paddler_tests/tests/agent_conversation_with_function_tool_succeeds.rs b/paddler_tests/tests/agent_conversation_with_function_tool_succeeds.rs index 7bd50fb1..f2cbf393 100644 --- a/paddler_tests/tests/agent_conversation_with_function_tool_succeeds.rs +++ b/paddler_tests/tests/agent_conversation_with_function_tool_succeeds.rs @@ -4,6 +4,7 @@ ))] use anyhow::Result; +use paddler_tests::agent_config::AgentConfig; use paddler_tests::collect_generated_tokens::collect_generated_tokens; use paddler_tests::inference_http_client::InferenceHttpClient; use paddler_tests::start_subprocess_cluster_with_qwen3::start_subprocess_cluster_with_qwen3; @@ -23,7 +24,7 @@ use serde_json::Value; #[serial_test::file_serial(model_load, path => "../target/model_load.lock")] #[tokio::test(flavor = "multi_thread")] async fn agent_conversation_with_function_tool_succeeds() -> Result<()> { - let cluster = start_subprocess_cluster_with_qwen3(2, 1).await?; + let cluster = start_subprocess_cluster_with_qwen3(AgentConfig::uniform(1, 2)).await?; let inference_client = InferenceHttpClient::new(Client::new(), cluster.addresses.inference_base_url()?); diff --git a/paddler_tests/tests/agent_conversation_with_gbnf_grammar_constrains_output.rs b/paddler_tests/tests/agent_conversation_with_gbnf_grammar_constrains_output.rs index 95e56d1e..d38d1f83 100644 --- a/paddler_tests/tests/agent_conversation_with_gbnf_grammar_constrains_output.rs +++ b/paddler_tests/tests/agent_conversation_with_gbnf_grammar_constrains_output.rs @@ -4,6 +4,7 @@ ))] use anyhow::Result; +use paddler_tests::agent_config::AgentConfig; use paddler_tests::collect_generated_tokens::collect_generated_tokens; use paddler_tests::inference_http_client::InferenceHttpClient; use paddler_tests::start_subprocess_cluster_with_qwen3::start_subprocess_cluster_with_qwen3; @@ -17,7 +18,7 @@ use reqwest::Client; #[serial_test::file_serial(model_load, path => "../target/model_load.lock")] #[tokio::test(flavor = "multi_thread")] async fn agent_conversation_with_gbnf_grammar_constrains_output() -> Result<()> { - let cluster = start_subprocess_cluster_with_qwen3(2, 1).await?; + let cluster = start_subprocess_cluster_with_qwen3(AgentConfig::uniform(1, 2)).await?; let inference_client = InferenceHttpClient::new(Client::new(), cluster.addresses.inference_base_url()?); diff --git a/paddler_tests/tests/agent_conversation_with_json_schema_grammar_returns_valid_json.rs b/paddler_tests/tests/agent_conversation_with_json_schema_grammar_returns_valid_json.rs index 10d9d9d0..727eabcc 100644 --- a/paddler_tests/tests/agent_conversation_with_json_schema_grammar_returns_valid_json.rs +++ b/paddler_tests/tests/agent_conversation_with_json_schema_grammar_returns_valid_json.rs @@ -4,6 +4,7 @@ ))] use anyhow::Result; +use paddler_tests::agent_config::AgentConfig; use paddler_tests::collect_generated_tokens::collect_generated_tokens; use paddler_tests::inference_http_client::InferenceHttpClient; use paddler_tests::start_subprocess_cluster_with_qwen3::start_subprocess_cluster_with_qwen3; @@ -17,7 +18,7 @@ use reqwest::Client; #[serial_test::file_serial(model_load, path => "../target/model_load.lock")] #[tokio::test(flavor = "multi_thread")] async fn agent_conversation_with_json_schema_grammar_returns_valid_json() -> Result<()> { - let cluster = start_subprocess_cluster_with_qwen3(2, 1).await?; + let cluster = start_subprocess_cluster_with_qwen3(AgentConfig::uniform(1, 2)).await?; let inference_client = InferenceHttpClient::new(Client::new(), cluster.addresses.inference_base_url()?); diff --git a/paddler_tests/tests/agent_conversation_without_grammar_field_succeeds.rs b/paddler_tests/tests/agent_conversation_without_grammar_field_succeeds.rs index 700e7640..4bc10a38 100644 --- a/paddler_tests/tests/agent_conversation_without_grammar_field_succeeds.rs +++ b/paddler_tests/tests/agent_conversation_without_grammar_field_succeeds.rs @@ -5,13 +5,14 @@ use anyhow::Context as _; use anyhow::Result; +use paddler_tests::agent_config::AgentConfig; use paddler_tests::start_subprocess_cluster_with_qwen3::start_subprocess_cluster_with_qwen3; use serde_json::json; #[serial_test::file_serial(model_load, path => "../target/model_load.lock")] #[tokio::test(flavor = "multi_thread")] async fn agent_conversation_without_grammar_field_succeeds() -> Result<()> { - let cluster = start_subprocess_cluster_with_qwen3(2, 1).await?; + let cluster = start_subprocess_cluster_with_qwen3(AgentConfig::uniform(1, 2)).await?; let inference_url = cluster .addresses diff --git a/paddler_tests/tests/agent_does_not_crash_on_oversized_image.rs b/paddler_tests/tests/agent_does_not_crash_on_oversized_image.rs index 7a0826ab..eb1daf1a 100644 --- a/paddler_tests/tests/agent_does_not_crash_on_oversized_image.rs +++ b/paddler_tests/tests/agent_does_not_crash_on_oversized_image.rs @@ -6,6 +6,7 @@ use anyhow::Context as _; use anyhow::Result; use base64::Engine as _; use base64::engine::general_purpose::STANDARD as BASE64_STANDARD; +use paddler_tests::agent_config::AgentConfig; use paddler_tests::collect_generated_tokens::collect_generated_tokens; use paddler_tests::inference_http_client::InferenceHttpClient; use paddler_tests::start_in_process_cluster_with_smolvlm2_and_n_batch::start_in_process_cluster_with_smolvlm2_and_n_batch; @@ -19,10 +20,7 @@ use paddler_types::request_params::continue_from_conversation_history_params::Co use reqwest::Client; fn load_fixture_as_data_uri(fixture_name: &str, mime_type: &str) -> Result { - let fixture_path = format!( - "{}/../fixtures/{fixture_name}", - env!("CARGO_MANIFEST_DIR") - ); + let fixture_path = format!("{}/../fixtures/{fixture_name}", env!("CARGO_MANIFEST_DIR")); let bytes = fs::read(&fixture_path) .with_context(|| format!("failed to read test fixture {fixture_path}"))?; let encoded = BASE64_STANDARD.encode(&bytes); @@ -86,7 +84,8 @@ async fn drive_oversized_image_fixture( #[serial_test::file_serial(model_load, path => "../target/model_load.lock")] #[tokio::test(flavor = "multi_thread")] async fn agent_does_not_crash_on_oversized_image() -> Result<()> { - let cluster = start_in_process_cluster_with_smolvlm2_and_n_batch(1, 32).await?; + let cluster = + start_in_process_cluster_with_smolvlm2_and_n_batch(AgentConfig::single(1), 32).await?; let inference_client = InferenceHttpClient::new(Client::new(), cluster.addresses.inference_base_url()?); diff --git a/paddler_tests/tests/agent_embedding_batch_distribution_independent_of_context_size.rs b/paddler_tests/tests/agent_embedding_batch_distribution_independent_of_context_size.rs index e59d2c2c..dbefb692 100644 --- a/paddler_tests/tests/agent_embedding_batch_distribution_independent_of_context_size.rs +++ b/paddler_tests/tests/agent_embedding_batch_distribution_independent_of_context_size.rs @@ -3,6 +3,7 @@ use std::collections::BTreeSet; use anyhow::Result; +use paddler_tests::agent_config::AgentConfig; use paddler_tests::collect_embedding_results::collect_embedding_results; use paddler_tests::inference_http_client::InferenceHttpClient; use paddler_tests::start_in_process_embedding_cluster::start_in_process_embedding_cluster; @@ -22,7 +23,7 @@ async fn agent_embedding_batch_distribution_independent_of_context_size() -> Res enable_embeddings: true, ..InferenceParameters::default() }, - 4, + AgentConfig::single(4), ) .await?; diff --git a/paddler_tests/tests/agent_embedding_batch_returns_one_embedding_per_input_document.rs b/paddler_tests/tests/agent_embedding_batch_returns_one_embedding_per_input_document.rs index 76fec83a..d09b1842 100644 --- a/paddler_tests/tests/agent_embedding_batch_returns_one_embedding_per_input_document.rs +++ b/paddler_tests/tests/agent_embedding_batch_returns_one_embedding_per_input_document.rs @@ -3,6 +3,7 @@ use std::collections::BTreeSet; use anyhow::Result; +use paddler_tests::agent_config::AgentConfig; use paddler_tests::collect_embedding_results::collect_embedding_results; use paddler_tests::inference_http_client::InferenceHttpClient; use paddler_tests::start_in_process_embedding_cluster::start_in_process_embedding_cluster; @@ -20,7 +21,7 @@ async fn agent_embedding_batch_returns_one_embedding_per_input_document() -> Res enable_embeddings: true, ..InferenceParameters::default() }, - 1, + AgentConfig::single(1), ) .await?; diff --git a/paddler_tests/tests/agent_embedding_document_exceeds_n_batch.rs b/paddler_tests/tests/agent_embedding_document_exceeds_n_batch.rs new file mode 100644 index 00000000..4c238f4a --- /dev/null +++ b/paddler_tests/tests/agent_embedding_document_exceeds_n_batch.rs @@ -0,0 +1,100 @@ +#![cfg(feature = "tests_that_use_llms")] + +use anyhow::Result; +use paddler_tests::agent_config::AgentConfig; +use paddler_tests::collect_embedding_results::collect_embedding_results; +use paddler_tests::inference_http_client::InferenceHttpClient; +use paddler_tests::start_in_process_embedding_cluster::start_in_process_embedding_cluster; +use paddler_types::embedding_input_document::EmbeddingInputDocument; +use paddler_types::embedding_normalization_method::EmbeddingNormalizationMethod; +use paddler_types::inference_parameters::InferenceParameters; +use paddler_types::request_params::GenerateEmbeddingBatchParams; +use reqwest::Client; + +const N_BATCH: u32 = 64; + +#[serial_test::file_serial(model_load, path => "../target/model_load.lock")] +#[tokio::test(flavor = "multi_thread")] +async fn agent_embedding_document_exceeds_n_batch() -> Result<()> { + let cluster = start_in_process_embedding_cluster( + InferenceParameters { + n_batch: N_BATCH as usize, + context_size: 4096, + enable_embeddings: true, + ..InferenceParameters::default() + }, + AgentConfig::single(1), + ) + .await?; + + let inference_client = + InferenceHttpClient::new(Client::new(), cluster.addresses.inference_base_url()?); + + let huge_content = "The quick brown fox jumps over the lazy dog. ".repeat(40); + + let stream = inference_client + .post_generate_embedding_batch(&GenerateEmbeddingBatchParams { + input_batch: vec![ + EmbeddingInputDocument { + content: "ok".to_owned(), + id: "tiny".to_owned(), + }, + EmbeddingInputDocument { + content: huge_content, + id: "huge".to_owned(), + }, + ], + normalization_method: EmbeddingNormalizationMethod::None, + }) + .await?; + + let collected = collect_embedding_results(stream).await?; + + assert!( + collected.saw_done, + "stream must terminate with Done even when one document is oversized", + ); + assert!( + collected.errors.is_empty(), + "no generic EmbeddingResult::Error events should be emitted; got {:?}", + collected.errors, + ); + + assert_eq!( + collected.oversized_documents.len(), + 1, + "exactly one DocumentExceedsBatchSize event expected; got {:?}", + collected + .oversized_documents + .iter() + .map(|details| &details.source_document_id) + .collect::>(), + ); + + let oversized = &collected.oversized_documents[0]; + + assert_eq!(oversized.source_document_id, "huge"); + assert_eq!(oversized.n_batch, N_BATCH); + assert!( + oversized.document_tokens > oversized.n_batch, + "document_tokens ({}) must exceed n_batch ({}) for the assertion to be meaningful", + oversized.document_tokens, + oversized.n_batch, + ); + + assert_eq!( + collected.embeddings.len(), + 1, + "the small document must still be embedded; got {:?}", + collected + .embeddings + .iter() + .map(|produced| &produced.embedding.source_document_id) + .collect::>(), + ); + assert_eq!(collected.embeddings[0].embedding.source_document_id, "tiny",); + + cluster.shutdown().await?; + + Ok(()) +} diff --git a/paddler_tests/tests/agent_embeddings_share_dimension_across_inputs_of_varying_length.rs b/paddler_tests/tests/agent_embeddings_share_dimension_across_inputs_of_varying_length.rs index c6c1d2d6..d9eae9d6 100644 --- a/paddler_tests/tests/agent_embeddings_share_dimension_across_inputs_of_varying_length.rs +++ b/paddler_tests/tests/agent_embeddings_share_dimension_across_inputs_of_varying_length.rs @@ -1,6 +1,7 @@ #![cfg(feature = "tests_that_use_llms")] use anyhow::Result; +use paddler_tests::agent_config::AgentConfig; use paddler_tests::collect_embedding_results::collect_embedding_results; use paddler_tests::inference_http_client::InferenceHttpClient; use paddler_tests::start_in_process_embedding_cluster::start_in_process_embedding_cluster; @@ -18,7 +19,7 @@ async fn agent_embeddings_share_dimension_across_inputs_of_varying_length() -> R enable_embeddings: true, ..InferenceParameters::default() }, - 1, + AgentConfig::single(1), ) .await?; diff --git a/paddler_tests/tests/agent_exits_cleanly_on_sigterm_during_multimodal_inference.rs b/paddler_tests/tests/agent_exits_cleanly_on_sigterm_during_multimodal_inference.rs index 8064deca..5ad7e18c 100644 --- a/paddler_tests/tests/agent_exits_cleanly_on_sigterm_during_multimodal_inference.rs +++ b/paddler_tests/tests/agent_exits_cleanly_on_sigterm_during_multimodal_inference.rs @@ -46,7 +46,7 @@ async fn agent_exits_cleanly_on_sigterm_during_multimodal_inference() -> Result< } = qwen2_5_vl_3b_mmproj(); let mut cluster = start_subprocess_cluster(SubprocessClusterParams { - agent_count: 0, + agents: Vec::new(), wait_for_slots_ready: false, desired_state: Some(BalancerDesiredState { chat_template_override: None, diff --git a/paddler_tests/tests/agent_exits_cleanly_when_killed_during_generation.rs b/paddler_tests/tests/agent_exits_cleanly_when_killed_during_generation.rs index 82b48cb2..15a341da 100644 --- a/paddler_tests/tests/agent_exits_cleanly_when_killed_during_generation.rs +++ b/paddler_tests/tests/agent_exits_cleanly_when_killed_during_generation.rs @@ -35,7 +35,7 @@ async fn agent_exits_cleanly_when_killed_during_generation() -> Result<()> { } = qwen3_0_6b(); let mut cluster = start_subprocess_cluster(SubprocessClusterParams { - agent_count: 0, + agents: Vec::new(), wait_for_slots_ready: false, desired_state: Some(BalancerDesiredState { chat_template_override: None, diff --git a/paddler_tests/tests/agent_grammar_with_thinking_returns_incompatible_error.rs b/paddler_tests/tests/agent_grammar_with_thinking_returns_incompatible_error.rs index 691bd392..649c38f6 100644 --- a/paddler_tests/tests/agent_grammar_with_thinking_returns_incompatible_error.rs +++ b/paddler_tests/tests/agent_grammar_with_thinking_returns_incompatible_error.rs @@ -4,6 +4,7 @@ ))] use anyhow::Result; +use paddler_tests::agent_config::AgentConfig; use paddler_tests::collect_generated_tokens::collect_generated_tokens; use paddler_tests::inference_http_client::InferenceHttpClient; use paddler_tests::start_subprocess_cluster_with_qwen3::start_subprocess_cluster_with_qwen3; @@ -18,7 +19,7 @@ use reqwest::Client; #[serial_test::file_serial(model_load, path => "../target/model_load.lock")] #[tokio::test(flavor = "multi_thread")] async fn agent_grammar_with_thinking_returns_incompatible_error() -> Result<()> { - let cluster = start_subprocess_cluster_with_qwen3(2, 1).await?; + let cluster = start_subprocess_cluster_with_qwen3(AgentConfig::uniform(1, 2)).await?; let inference_client = InferenceHttpClient::new(Client::new(), cluster.addresses.inference_base_url()?); diff --git a/paddler_tests/tests/agent_isolates_concurrent_embedding_requests_per_client.rs b/paddler_tests/tests/agent_isolates_concurrent_embedding_requests_per_client.rs index f274995b..fe6094d2 100644 --- a/paddler_tests/tests/agent_isolates_concurrent_embedding_requests_per_client.rs +++ b/paddler_tests/tests/agent_isolates_concurrent_embedding_requests_per_client.rs @@ -3,6 +3,7 @@ use std::collections::BTreeSet; use anyhow::Result; +use paddler_tests::agent_config::AgentConfig; use paddler_tests::collect_embedding_results::collect_embedding_results; use paddler_tests::inference_http_client::InferenceHttpClient; use paddler_tests::start_in_process_embedding_cluster::start_in_process_embedding_cluster; @@ -23,7 +24,7 @@ async fn agent_isolates_concurrent_embedding_requests_per_client() -> Result<()> enable_embeddings: true, ..InferenceParameters::default() }, - 4, + AgentConfig::single(4), ) .await?; diff --git a/paddler_tests/tests/agent_l2_normalized_embeddings_have_unit_norm.rs b/paddler_tests/tests/agent_l2_normalized_embeddings_have_unit_norm.rs index 29047da4..153ee20d 100644 --- a/paddler_tests/tests/agent_l2_normalized_embeddings_have_unit_norm.rs +++ b/paddler_tests/tests/agent_l2_normalized_embeddings_have_unit_norm.rs @@ -1,6 +1,7 @@ #![cfg(feature = "tests_that_use_llms")] use anyhow::Result; +use paddler_tests::agent_config::AgentConfig; use paddler_tests::collect_embedding_results::collect_embedding_results; use paddler_tests::inference_http_client::InferenceHttpClient; use paddler_tests::start_in_process_embedding_cluster::start_in_process_embedding_cluster; @@ -18,7 +19,7 @@ async fn agent_l2_normalized_embeddings_have_unit_norm() -> Result<()> { enable_embeddings: true, ..InferenceParameters::default() }, - 1, + AgentConfig::single(1), ) .await?; diff --git a/paddler_tests/tests/agent_openai_chat_completions_non_streaming_returns_text.rs b/paddler_tests/tests/agent_openai_chat_completions_non_streaming_returns_text.rs index 6982b71a..ece35f61 100644 --- a/paddler_tests/tests/agent_openai_chat_completions_non_streaming_returns_text.rs +++ b/paddler_tests/tests/agent_openai_chat_completions_non_streaming_returns_text.rs @@ -5,13 +5,14 @@ use anyhow::Context as _; use anyhow::Result; +use paddler_tests::agent_config::AgentConfig; use paddler_tests::start_subprocess_cluster_with_qwen3::start_subprocess_cluster_with_qwen3; use serde_json::json; #[serial_test::file_serial(model_load, path => "../target/model_load.lock")] #[tokio::test(flavor = "multi_thread")] async fn agent_openai_chat_completions_non_streaming_returns_text() -> Result<()> { - let cluster = start_subprocess_cluster_with_qwen3(2, 1).await?; + let cluster = start_subprocess_cluster_with_qwen3(AgentConfig::uniform(1, 2)).await?; let openai_url = cluster .addresses diff --git a/paddler_tests/tests/agent_openai_chat_completions_streaming_returns_chunks.rs b/paddler_tests/tests/agent_openai_chat_completions_streaming_returns_chunks.rs index 7331ed83..a912ea54 100644 --- a/paddler_tests/tests/agent_openai_chat_completions_streaming_returns_chunks.rs +++ b/paddler_tests/tests/agent_openai_chat_completions_streaming_returns_chunks.rs @@ -5,13 +5,14 @@ use anyhow::Context as _; use anyhow::Result; +use paddler_tests::agent_config::AgentConfig; use paddler_tests::start_subprocess_cluster_with_qwen3::start_subprocess_cluster_with_qwen3; use serde_json::json; #[serial_test::file_serial(model_load, path => "../target/model_load.lock")] #[tokio::test(flavor = "multi_thread")] async fn agent_openai_chat_completions_streaming_returns_chunks() -> Result<()> { - let cluster = start_subprocess_cluster_with_qwen3(2, 1).await?; + let cluster = start_subprocess_cluster_with_qwen3(AgentConfig::uniform(1, 2)).await?; let openai_url = cluster .addresses diff --git a/paddler_tests/tests/agent_raw_prompt_respects_max_tokens.rs b/paddler_tests/tests/agent_raw_prompt_respects_max_tokens.rs index 714aaa43..288ccaa8 100644 --- a/paddler_tests/tests/agent_raw_prompt_respects_max_tokens.rs +++ b/paddler_tests/tests/agent_raw_prompt_respects_max_tokens.rs @@ -4,6 +4,7 @@ ))] use anyhow::Result; +use paddler_tests::agent_config::AgentConfig; use paddler_tests::collect_generated_tokens::collect_generated_tokens; use paddler_tests::inference_http_client::InferenceHttpClient; use paddler_tests::start_subprocess_cluster_with_qwen3::start_subprocess_cluster_with_qwen3; @@ -13,7 +14,7 @@ use reqwest::Client; #[serial_test::file_serial(model_load, path => "../target/model_load.lock")] #[tokio::test(flavor = "multi_thread")] async fn agent_raw_prompt_respects_max_tokens() -> Result<()> { - let cluster = start_subprocess_cluster_with_qwen3(2, 1).await?; + let cluster = start_subprocess_cluster_with_qwen3(AgentConfig::uniform(1, 2)).await?; let inference_client = InferenceHttpClient::new(Client::new(), cluster.addresses.inference_base_url()?); diff --git a/paddler_tests/tests/agent_raw_prompt_with_gbnf_grammar_constrains_output.rs b/paddler_tests/tests/agent_raw_prompt_with_gbnf_grammar_constrains_output.rs index 6bef4a8a..696a0db0 100644 --- a/paddler_tests/tests/agent_raw_prompt_with_gbnf_grammar_constrains_output.rs +++ b/paddler_tests/tests/agent_raw_prompt_with_gbnf_grammar_constrains_output.rs @@ -4,6 +4,7 @@ ))] use anyhow::Result; +use paddler_tests::agent_config::AgentConfig; use paddler_tests::collect_generated_tokens::collect_generated_tokens; use paddler_tests::inference_http_client::InferenceHttpClient; use paddler_tests::start_subprocess_cluster_with_qwen3::start_subprocess_cluster_with_qwen3; @@ -14,7 +15,7 @@ use reqwest::Client; #[serial_test::file_serial(model_load, path => "../target/model_load.lock")] #[tokio::test(flavor = "multi_thread")] async fn agent_raw_prompt_with_gbnf_grammar_constrains_output() -> Result<()> { - let cluster = start_subprocess_cluster_with_qwen3(2, 1).await?; + let cluster = start_subprocess_cluster_with_qwen3(AgentConfig::uniform(1, 2)).await?; let inference_client = InferenceHttpClient::new(Client::new(), cluster.addresses.inference_base_url()?); diff --git a/paddler_tests/tests/agent_raw_prompt_without_grammar_field_succeeds.rs b/paddler_tests/tests/agent_raw_prompt_without_grammar_field_succeeds.rs index 11fa358c..44611f21 100644 --- a/paddler_tests/tests/agent_raw_prompt_without_grammar_field_succeeds.rs +++ b/paddler_tests/tests/agent_raw_prompt_without_grammar_field_succeeds.rs @@ -5,13 +5,14 @@ use anyhow::Context as _; use anyhow::Result; +use paddler_tests::agent_config::AgentConfig; use paddler_tests::start_subprocess_cluster_with_qwen3::start_subprocess_cluster_with_qwen3; use serde_json::json; #[serial_test::file_serial(model_load, path => "../target/model_load.lock")] #[tokio::test(flavor = "multi_thread")] async fn agent_raw_prompt_without_grammar_field_succeeds() -> Result<()> { - let cluster = start_subprocess_cluster_with_qwen3(2, 1).await?; + let cluster = start_subprocess_cluster_with_qwen3(AgentConfig::uniform(1, 2)).await?; let inference_url = cluster .addresses diff --git a/paddler_tests/tests/agent_rejects_tool_with_invalid_required_field_in_schema.rs b/paddler_tests/tests/agent_rejects_tool_with_invalid_required_field_in_schema.rs index 15e4baf2..7b4b6835 100644 --- a/paddler_tests/tests/agent_rejects_tool_with_invalid_required_field_in_schema.rs +++ b/paddler_tests/tests/agent_rejects_tool_with_invalid_required_field_in_schema.rs @@ -4,6 +4,7 @@ ))] use anyhow::Result; +use paddler_tests::agent_config::AgentConfig; use paddler_tests::inference_http_client::InferenceHttpClient; use paddler_tests::start_subprocess_cluster_with_qwen3::start_subprocess_cluster_with_qwen3; use paddler_types::conversation_history::ConversationHistory; @@ -21,7 +22,7 @@ use serde_json::Map; #[serial_test::file_serial(model_load, path => "../target/model_load.lock")] #[tokio::test(flavor = "multi_thread")] async fn agent_rejects_tool_with_invalid_required_field_in_schema() -> Result<()> { - let cluster = start_subprocess_cluster_with_qwen3(2, 1).await?; + let cluster = start_subprocess_cluster_with_qwen3(AgentConfig::uniform(1, 2)).await?; let inference_client = InferenceHttpClient::new(Client::new(), cluster.addresses.inference_base_url()?); diff --git a/paddler_tests/tests/agent_releases_slot_when_websocket_client_disconnects.rs b/paddler_tests/tests/agent_releases_slot_when_websocket_client_disconnects.rs index 914ad4a9..5cb088f0 100644 --- a/paddler_tests/tests/agent_releases_slot_when_websocket_client_disconnects.rs +++ b/paddler_tests/tests/agent_releases_slot_when_websocket_client_disconnects.rs @@ -6,6 +6,7 @@ use anyhow::Context as _; use anyhow::Result; use futures_util::StreamExt as _; +use paddler_tests::agent_config::AgentConfig; use paddler_tests::agents_status::assert_slots_processing::assert_slots_processing; use paddler_tests::inference_http_client::InferenceHttpClient; use paddler_tests::start_subprocess_cluster_with_qwen3::start_subprocess_cluster_with_qwen3; @@ -15,7 +16,7 @@ use reqwest::Client; #[serial_test::file_serial(model_load, path => "../target/model_load.lock")] #[tokio::test(flavor = "multi_thread")] async fn agent_releases_slot_when_websocket_client_disconnects() -> Result<()> { - let mut cluster = start_subprocess_cluster_with_qwen3(1, 1).await?; + let mut cluster = start_subprocess_cluster_with_qwen3(AgentConfig::uniform(1, 1)).await?; let agent_id = cluster .agent_ids diff --git a/paddler_tests/tests/agent_reports_slot_cannot_start_for_excessive_slots_in_process.rs b/paddler_tests/tests/agent_reports_slot_cannot_start_for_excessive_slots_in_process.rs index c8295928..6ea643af 100644 --- a/paddler_tests/tests/agent_reports_slot_cannot_start_for_excessive_slots_in_process.rs +++ b/paddler_tests/tests/agent_reports_slot_cannot_start_for_excessive_slots_in_process.rs @@ -7,6 +7,7 @@ use std::time::Duration; use anyhow::Context as _; use anyhow::Result; +use paddler_tests::agent_config::AgentConfig; use paddler_tests::current_test_device::current_test_device; use paddler_tests::in_process_cluster_params::InProcessClusterParams; use paddler_tests::model_card::ModelCard; @@ -31,8 +32,10 @@ async fn agent_reports_slot_cannot_start_for_excessive_slots_in_process() -> Res let inference_parameters = device.inference_parameters_for_full_offload(gpu_layer_count); let mut cluster = start_in_process_cluster(InProcessClusterParams { - spawn_agent: true, - slots_per_agent: 257, + agent: Some(AgentConfig { + name: "test-agent".to_owned(), + slot_count: 257, + }), desired_state: BalancerDesiredState { chat_template_override: None, inference_parameters, diff --git a/paddler_tests/tests/agent_reports_slot_cannot_start_for_excessive_slots_subprocess.rs b/paddler_tests/tests/agent_reports_slot_cannot_start_for_excessive_slots_subprocess.rs index ec17177c..5e5cd051 100644 --- a/paddler_tests/tests/agent_reports_slot_cannot_start_for_excessive_slots_subprocess.rs +++ b/paddler_tests/tests/agent_reports_slot_cannot_start_for_excessive_slots_subprocess.rs @@ -7,6 +7,7 @@ use std::time::Duration; use anyhow::Context as _; use anyhow::Result; +use paddler_tests::agent_config::AgentConfig; use paddler_tests::current_test_device::current_test_device; use paddler_tests::model_card::ModelCard; use paddler_tests::model_card::qwen3_0_6b::qwen3_0_6b; @@ -31,8 +32,7 @@ async fn agent_reports_slot_cannot_start_for_excessive_slots_subprocess() -> Res let inference_parameters = device.inference_parameters_for_full_offload(gpu_layer_count); let mut cluster = start_subprocess_cluster(SubprocessClusterParams { - agent_count: 1, - slots_per_agent: 257, + agents: AgentConfig::uniform(1, 257), wait_for_slots_ready: false, desired_state: Some(BalancerDesiredState { chat_template_override: None, diff --git a/paddler_tests/tests/agent_reports_slot_cannot_start_for_metal_quantized_distinct_kv_in_process.rs b/paddler_tests/tests/agent_reports_slot_cannot_start_for_metal_quantized_distinct_kv_in_process.rs index eba1e05c..9289c53d 100644 --- a/paddler_tests/tests/agent_reports_slot_cannot_start_for_metal_quantized_distinct_kv_in_process.rs +++ b/paddler_tests/tests/agent_reports_slot_cannot_start_for_metal_quantized_distinct_kv_in_process.rs @@ -8,6 +8,7 @@ use std::time::Duration; use anyhow::Context as _; use anyhow::Result; +use paddler_tests::agent_config::AgentConfig; use paddler_tests::current_test_device::current_test_device; use paddler_tests::in_process_cluster_params::InProcessClusterParams; use paddler_tests::model_card::ModelCard; @@ -42,8 +43,10 @@ async fn agent_reports_slot_cannot_start_for_metal_quantized_distinct_kv_in_proc inference_parameters.v_cache_dtype = KvCacheDtype::Q4_0; let mut cluster = start_in_process_cluster(InProcessClusterParams { - spawn_agent: true, - slots_per_agent: 1, + agent: Some(AgentConfig { + name: "test-agent".to_owned(), + slot_count: 1, + }), desired_state: BalancerDesiredState { chat_template_override: None, inference_parameters, diff --git a/paddler_tests/tests/agent_reports_slot_cannot_start_for_metal_quantized_distinct_kv_subprocess.rs b/paddler_tests/tests/agent_reports_slot_cannot_start_for_metal_quantized_distinct_kv_subprocess.rs index 9f9d1481..cbe534ab 100644 --- a/paddler_tests/tests/agent_reports_slot_cannot_start_for_metal_quantized_distinct_kv_subprocess.rs +++ b/paddler_tests/tests/agent_reports_slot_cannot_start_for_metal_quantized_distinct_kv_subprocess.rs @@ -8,6 +8,7 @@ use std::time::Duration; use anyhow::Context as _; use anyhow::Result; +use paddler_tests::agent_config::AgentConfig; use paddler_tests::current_test_device::current_test_device; use paddler_tests::model_card::ModelCard; use paddler_tests::model_card::qwen3_0_6b::qwen3_0_6b; @@ -42,8 +43,7 @@ async fn agent_reports_slot_cannot_start_for_metal_quantized_distinct_kv_subproc inference_parameters.v_cache_dtype = KvCacheDtype::Q4_0; let mut cluster = start_subprocess_cluster(SubprocessClusterParams { - agent_count: 1, - slots_per_agent: 1, + agents: AgentConfig::uniform(1, 1), wait_for_slots_ready: false, desired_state: Some(BalancerDesiredState { chat_template_override: None, diff --git a/paddler_tests/tests/agent_returns_identical_embeddings_for_identical_documents.rs b/paddler_tests/tests/agent_returns_identical_embeddings_for_identical_documents.rs index 3a7ca664..cc61d8b2 100644 --- a/paddler_tests/tests/agent_returns_identical_embeddings_for_identical_documents.rs +++ b/paddler_tests/tests/agent_returns_identical_embeddings_for_identical_documents.rs @@ -2,6 +2,7 @@ use anyhow::Context as _; use anyhow::Result; +use paddler_tests::agent_config::AgentConfig; use paddler_tests::collect_embedding_results::collect_embedding_results; use paddler_tests::inference_http_client::InferenceHttpClient; use paddler_tests::start_in_process_embedding_cluster::start_in_process_embedding_cluster; @@ -19,7 +20,7 @@ async fn agent_returns_identical_embeddings_for_identical_documents() -> Result< enable_embeddings: true, ..InferenceParameters::default() }, - 1, + AgentConfig::single(1), ) .await?; diff --git a/paddler_tests/tests/agent_returns_image_decoding_error_for_invalid_base64.rs b/paddler_tests/tests/agent_returns_image_decoding_error_for_invalid_base64.rs index 3fa1f7ae..e1e14b7e 100644 --- a/paddler_tests/tests/agent_returns_image_decoding_error_for_invalid_base64.rs +++ b/paddler_tests/tests/agent_returns_image_decoding_error_for_invalid_base64.rs @@ -4,6 +4,7 @@ ))] use anyhow::Result; +use paddler_tests::agent_config::AgentConfig; use paddler_tests::collect_generated_tokens::collect_generated_tokens; use paddler_tests::inference_http_client::InferenceHttpClient; use paddler_tests::start_subprocess_cluster_with_qwen3::start_subprocess_cluster_with_qwen3; @@ -19,7 +20,7 @@ use reqwest::Client; #[serial_test::file_serial(model_load, path => "../target/model_load.lock")] #[tokio::test(flavor = "multi_thread")] async fn agent_returns_image_decoding_error_for_invalid_base64() -> Result<()> { - let cluster = start_subprocess_cluster_with_qwen3(2, 1).await?; + let cluster = start_subprocess_cluster_with_qwen3(AgentConfig::uniform(1, 2)).await?; let inference_client = InferenceHttpClient::new(Client::new(), cluster.addresses.inference_base_url()?); diff --git a/paddler_tests/tests/agent_returns_image_decoding_error_for_malformed_data_uri.rs b/paddler_tests/tests/agent_returns_image_decoding_error_for_malformed_data_uri.rs index da1fc8f0..55a4a84b 100644 --- a/paddler_tests/tests/agent_returns_image_decoding_error_for_malformed_data_uri.rs +++ b/paddler_tests/tests/agent_returns_image_decoding_error_for_malformed_data_uri.rs @@ -4,6 +4,7 @@ ))] use anyhow::Result; +use paddler_tests::agent_config::AgentConfig; use paddler_tests::collect_generated_tokens::collect_generated_tokens; use paddler_tests::inference_http_client::InferenceHttpClient; use paddler_tests::start_subprocess_cluster_with_qwen3::start_subprocess_cluster_with_qwen3; @@ -19,7 +20,7 @@ use reqwest::Client; #[serial_test::file_serial(model_load, path => "../target/model_load.lock")] #[tokio::test(flavor = "multi_thread")] async fn agent_returns_image_decoding_error_for_malformed_data_uri() -> Result<()> { - let cluster = start_subprocess_cluster_with_qwen3(2, 1).await?; + let cluster = start_subprocess_cluster_with_qwen3(AgentConfig::uniform(1, 2)).await?; let inference_client = InferenceHttpClient::new(Client::new(), cluster.addresses.inference_base_url()?); diff --git a/paddler_tests/tests/agent_returns_image_decoding_error_for_remote_url.rs b/paddler_tests/tests/agent_returns_image_decoding_error_for_remote_url.rs index 4e00cddc..8ba81670 100644 --- a/paddler_tests/tests/agent_returns_image_decoding_error_for_remote_url.rs +++ b/paddler_tests/tests/agent_returns_image_decoding_error_for_remote_url.rs @@ -4,6 +4,7 @@ ))] use anyhow::Result; +use paddler_tests::agent_config::AgentConfig; use paddler_tests::collect_generated_tokens::collect_generated_tokens; use paddler_tests::inference_http_client::InferenceHttpClient; use paddler_tests::start_subprocess_cluster_with_qwen3::start_subprocess_cluster_with_qwen3; @@ -19,7 +20,7 @@ use reqwest::Client; #[serial_test::file_serial(model_load, path => "../target/model_load.lock")] #[tokio::test(flavor = "multi_thread")] async fn agent_returns_image_decoding_error_for_remote_url() -> Result<()> { - let cluster = start_subprocess_cluster_with_qwen3(2, 1).await?; + let cluster = start_subprocess_cluster_with_qwen3(AgentConfig::uniform(1, 2)).await?; let inference_client = InferenceHttpClient::new(Client::new(), cluster.addresses.inference_base_url()?); diff --git a/paddler_tests/tests/agent_returns_rms_normalized_embeddings_when_requested.rs b/paddler_tests/tests/agent_returns_rms_normalized_embeddings_when_requested.rs index cd33a648..31d0b471 100644 --- a/paddler_tests/tests/agent_returns_rms_normalized_embeddings_when_requested.rs +++ b/paddler_tests/tests/agent_returns_rms_normalized_embeddings_when_requested.rs @@ -1,6 +1,7 @@ #![cfg(feature = "tests_that_use_llms")] use anyhow::Result; +use paddler_tests::agent_config::AgentConfig; use paddler_tests::collect_embedding_results::collect_embedding_results; use paddler_tests::inference_http_client::InferenceHttpClient; use paddler_tests::start_in_process_embedding_cluster::start_in_process_embedding_cluster; @@ -18,7 +19,7 @@ async fn agent_returns_rms_normalized_embeddings_when_requested() -> Result<()> enable_embeddings: true, ..InferenceParameters::default() }, - 1, + AgentConfig::single(1), ) .await?; diff --git a/paddler_tests/tests/agent_returns_unnormalized_embeddings_when_requested.rs b/paddler_tests/tests/agent_returns_unnormalized_embeddings_when_requested.rs index a135d9d0..45f68685 100644 --- a/paddler_tests/tests/agent_returns_unnormalized_embeddings_when_requested.rs +++ b/paddler_tests/tests/agent_returns_unnormalized_embeddings_when_requested.rs @@ -1,6 +1,7 @@ #![cfg(feature = "tests_that_use_llms")] use anyhow::Result; +use paddler_tests::agent_config::AgentConfig; use paddler_tests::collect_embedding_results::collect_embedding_results; use paddler_tests::inference_http_client::InferenceHttpClient; use paddler_tests::start_in_process_embedding_cluster::start_in_process_embedding_cluster; @@ -18,7 +19,7 @@ async fn agent_returns_unnormalized_embeddings_when_requested() -> Result<()> { enable_embeddings: true, ..InferenceParameters::default() }, - 1, + AgentConfig::single(1), ) .await?; diff --git a/paddler_tests/tests/agent_serves_four_concurrent_clients_streaming_tokens.rs b/paddler_tests/tests/agent_serves_four_concurrent_clients_streaming_tokens.rs index b9b1ebac..7a3a2bd9 100644 --- a/paddler_tests/tests/agent_serves_four_concurrent_clients_streaming_tokens.rs +++ b/paddler_tests/tests/agent_serves_four_concurrent_clients_streaming_tokens.rs @@ -4,6 +4,7 @@ ))] use anyhow::Result; +use paddler_tests::agent_config::AgentConfig; use paddler_tests::collect_generated_tokens::collect_generated_tokens; use paddler_tests::inference_http_client::InferenceHttpClient; use paddler_tests::start_subprocess_cluster_with_qwen3::start_subprocess_cluster_with_qwen3; @@ -13,7 +14,7 @@ use reqwest::Client; #[serial_test::file_serial(model_load, path => "../target/model_load.lock")] #[tokio::test(flavor = "multi_thread")] async fn agent_serves_four_concurrent_clients_streaming_tokens() -> Result<()> { - let cluster = start_subprocess_cluster_with_qwen3(4, 1).await?; + let cluster = start_subprocess_cluster_with_qwen3(AgentConfig::uniform(1, 4)).await?; let inference_base_url = cluster.addresses.inference_base_url()?; diff --git a/paddler_tests/tests/agent_streams_tokens_from_conversation_history_over_http.rs b/paddler_tests/tests/agent_streams_tokens_from_conversation_history_over_http.rs index b423243d..fdb10902 100644 --- a/paddler_tests/tests/agent_streams_tokens_from_conversation_history_over_http.rs +++ b/paddler_tests/tests/agent_streams_tokens_from_conversation_history_over_http.rs @@ -4,6 +4,7 @@ ))] use anyhow::Result; +use paddler_tests::agent_config::AgentConfig; use paddler_tests::collect_generated_tokens::collect_generated_tokens; use paddler_tests::inference_http_client::InferenceHttpClient; use paddler_tests::start_subprocess_cluster_with_qwen3::start_subprocess_cluster_with_qwen3; @@ -16,7 +17,7 @@ use reqwest::Client; #[serial_test::file_serial(model_load, path => "../target/model_load.lock")] #[tokio::test(flavor = "multi_thread")] async fn agent_streams_tokens_from_conversation_history_over_http() -> Result<()> { - let cluster = start_subprocess_cluster_with_qwen3(2, 1).await?; + let cluster = start_subprocess_cluster_with_qwen3(AgentConfig::uniform(1, 2)).await?; let inference_client = InferenceHttpClient::new(Client::new(), cluster.addresses.inference_base_url()?); diff --git a/paddler_tests/tests/agent_streams_tokens_from_image_data_uri.rs b/paddler_tests/tests/agent_streams_tokens_from_image_data_uri.rs index a57943b9..d9074177 100644 --- a/paddler_tests/tests/agent_streams_tokens_from_image_data_uri.rs +++ b/paddler_tests/tests/agent_streams_tokens_from_image_data_uri.rs @@ -4,6 +4,7 @@ ))] use anyhow::Result; +use paddler_tests::agent_config::AgentConfig; use paddler_tests::collect_generated_tokens::collect_generated_tokens; use paddler_tests::inference_http_client::InferenceHttpClient; use paddler_tests::load_test_image_data_uri::load_test_image_data_uri; @@ -19,7 +20,7 @@ use reqwest::Client; #[serial_test::file_serial(model_load, path => "../target/model_load.lock")] #[tokio::test(flavor = "multi_thread")] async fn agent_streams_tokens_from_image_data_uri() -> Result<()> { - let cluster = start_subprocess_cluster_with_smolvlm2(4, 1).await?; + let cluster = start_subprocess_cluster_with_smolvlm2(AgentConfig::uniform(1, 4)).await?; let inference_client = InferenceHttpClient::new(Client::new(), cluster.addresses.inference_base_url()?); diff --git a/paddler_tests/tests/agent_streams_tokens_from_raw_prompt.rs b/paddler_tests/tests/agent_streams_tokens_from_raw_prompt.rs index a1049a6a..e3baa1f2 100644 --- a/paddler_tests/tests/agent_streams_tokens_from_raw_prompt.rs +++ b/paddler_tests/tests/agent_streams_tokens_from_raw_prompt.rs @@ -4,6 +4,7 @@ ))] use anyhow::Result; +use paddler_tests::agent_config::AgentConfig; use paddler_tests::collect_generated_tokens::collect_generated_tokens; use paddler_tests::inference_http_client::InferenceHttpClient; use paddler_tests::start_subprocess_cluster_with_qwen3::start_subprocess_cluster_with_qwen3; @@ -13,7 +14,7 @@ use reqwest::Client; #[serial_test::file_serial(model_load, path => "../target/model_load.lock")] #[tokio::test(flavor = "multi_thread")] async fn agent_streams_tokens_from_raw_prompt() -> Result<()> { - let cluster = start_subprocess_cluster_with_qwen3(2, 1).await?; + let cluster = start_subprocess_cluster_with_qwen3(AgentConfig::uniform(1, 2)).await?; let inference_client = InferenceHttpClient::new(Client::new(), cluster.addresses.inference_base_url()?); diff --git a/paddler_tests/tests/agent_text_only_model_rejects_image_input.rs b/paddler_tests/tests/agent_text_only_model_rejects_image_input.rs index 2172a1ee..7bf0facc 100644 --- a/paddler_tests/tests/agent_text_only_model_rejects_image_input.rs +++ b/paddler_tests/tests/agent_text_only_model_rejects_image_input.rs @@ -4,6 +4,7 @@ ))] use anyhow::Result; +use paddler_tests::agent_config::AgentConfig; use paddler_tests::collect_generated_tokens::collect_generated_tokens; use paddler_tests::inference_http_client::InferenceHttpClient; use paddler_tests::load_test_image_data_uri::load_test_image_data_uri; @@ -20,7 +21,7 @@ use reqwest::Client; #[serial_test::file_serial(model_load, path => "../target/model_load.lock")] #[tokio::test(flavor = "multi_thread")] async fn agent_text_only_model_rejects_image_input() -> Result<()> { - let cluster = start_subprocess_cluster_with_qwen3(2, 1).await?; + let cluster = start_subprocess_cluster_with_qwen3(AgentConfig::uniform(1, 2)).await?; let inference_client = InferenceHttpClient::new(Client::new(), cluster.addresses.inference_base_url()?); diff --git a/paddler_tests/tests/balancer_closes_management_websocket_on_sigterm.rs b/paddler_tests/tests/balancer_closes_management_websocket_on_sigterm.rs index 588fff33..7ed99c42 100644 --- a/paddler_tests/tests/balancer_closes_management_websocket_on_sigterm.rs +++ b/paddler_tests/tests/balancer_closes_management_websocket_on_sigterm.rs @@ -12,7 +12,7 @@ use tokio_tungstenite::tungstenite::protocol::Message; #[tokio::test(flavor = "multi_thread")] async fn balancer_closes_management_websocket_on_sigterm() -> Result<()> { let cluster = start_subprocess_cluster(SubprocessClusterParams { - agent_count: 0, + agents: Vec::new(), wait_for_slots_ready: false, ..SubprocessClusterParams::default() }) diff --git a/paddler_tests/tests/balancer_completes_buffered_request_after_agent_joins.rs b/paddler_tests/tests/balancer_completes_buffered_request_after_agent_joins.rs index 30cc6423..8dca6351 100644 --- a/paddler_tests/tests/balancer_completes_buffered_request_after_agent_joins.rs +++ b/paddler_tests/tests/balancer_completes_buffered_request_after_agent_joins.rs @@ -36,7 +36,7 @@ async fn balancer_completes_buffered_request_after_agent_joins() -> Result<()> { } = qwen3_0_6b(); let mut cluster = start_subprocess_cluster(SubprocessClusterParams { - agent_count: 0, + agents: Vec::new(), wait_for_slots_ready: false, buffered_request_timeout: Duration::from_secs(120), max_buffered_requests: 1, diff --git a/paddler_tests/tests/balancer_completes_in_flight_inference_during_model_switch.rs b/paddler_tests/tests/balancer_completes_in_flight_inference_during_model_switch.rs index e0bce8e2..3cee6e2b 100644 --- a/paddler_tests/tests/balancer_completes_in_flight_inference_during_model_switch.rs +++ b/paddler_tests/tests/balancer_completes_in_flight_inference_during_model_switch.rs @@ -6,6 +6,7 @@ use anyhow::Result; use anyhow::anyhow; use futures_util::StreamExt as _; +use paddler_tests::agent_config::AgentConfig; use paddler_tests::collect_generated_tokens::collect_generated_tokens; use paddler_tests::inference_http_client::InferenceHttpClient; use paddler_tests::start_subprocess_cluster_with_qwen3::start_subprocess_cluster_with_qwen3; @@ -21,7 +22,7 @@ use reqwest::Client; #[serial_test::file_serial(model_load, path => "../target/model_load.lock")] #[tokio::test(flavor = "multi_thread")] async fn balancer_completes_in_flight_inference_during_model_switch() -> Result<()> { - let cluster = start_subprocess_cluster_with_qwen3(1, 1).await?; + let cluster = start_subprocess_cluster_with_qwen3(AgentConfig::uniform(1, 1)).await?; let inference_client = InferenceHttpClient::new(Client::new(), cluster.addresses.inference_base_url()?); diff --git a/paddler_tests/tests/balancer_distributes_buffered_requests_across_two_agents.rs b/paddler_tests/tests/balancer_distributes_buffered_requests_across_two_agents.rs index 8b887155..baccd731 100644 --- a/paddler_tests/tests/balancer_distributes_buffered_requests_across_two_agents.rs +++ b/paddler_tests/tests/balancer_distributes_buffered_requests_across_two_agents.rs @@ -7,6 +7,7 @@ use std::time::Duration; use anyhow::Result; use futures_util::StreamExt as _; +use paddler_tests::agent_config::AgentConfig; use paddler_tests::current_test_device::current_test_device; use paddler_tests::inference_http_client::InferenceHttpClient; use paddler_tests::model_card::ModelCard; @@ -32,12 +33,19 @@ async fn balancer_distributes_buffered_requests_across_two_agents() -> Result<() } = qwen3_0_6b(); let cluster = start_subprocess_cluster(SubprocessClusterParams { - agent_count: 2, - slots_per_agent: 2, + agents: vec![ + AgentConfig { + name: "distributed-agent-0".to_owned(), + slot_count: 2, + }, + AgentConfig { + name: "distributed-agent-1".to_owned(), + slot_count: 2, + }, + ], wait_for_slots_ready: true, buffered_request_timeout: Duration::from_secs(120), max_buffered_requests: 10, - agent_name_prefix: "distributed-agent".to_owned(), desired_state: Some(BalancerDesiredState { chat_template_override: None, inference_parameters: device.inference_parameters_for_full_offload(gpu_layer_count), diff --git a/paddler_tests/tests/balancer_distributes_embedding_batch_across_agents.rs b/paddler_tests/tests/balancer_distributes_embedding_batch_across_agents.rs index 58fcdd32..9ccf57c4 100644 --- a/paddler_tests/tests/balancer_distributes_embedding_batch_across_agents.rs +++ b/paddler_tests/tests/balancer_distributes_embedding_batch_across_agents.rs @@ -6,8 +6,10 @@ use std::collections::BTreeSet; use anyhow::Result; +use paddler_tests::agent_config::AgentConfig; use paddler_tests::collect_embedding_results::collect_embedding_results; use paddler_tests::inference_http_client::InferenceHttpClient; +use paddler_tests::qwen3_embedding_cluster_params::Qwen3EmbeddingClusterParams; use paddler_tests::start_subprocess_cluster_with_qwen3_embedding::start_subprocess_cluster_with_qwen3_embedding; use paddler_types::embedding_input_document::EmbeddingInputDocument; use paddler_types::embedding_normalization_method::EmbeddingNormalizationMethod; @@ -18,14 +20,14 @@ use reqwest::Client; #[serial_test::file_serial(model_load, path => "../target/model_load.lock")] #[tokio::test(flavor = "multi_thread")] async fn balancer_distributes_embedding_batch_across_agents() -> Result<()> { - let cluster = start_subprocess_cluster_with_qwen3_embedding( - InferenceParameters { + let cluster = start_subprocess_cluster_with_qwen3_embedding(Qwen3EmbeddingClusterParams { + agents: AgentConfig::uniform(2, 4), + inference_parameters: InferenceParameters { enable_embeddings: true, ..InferenceParameters::default() }, - 4, - 2, - ) + ..Qwen3EmbeddingClusterParams::default() + }) .await?; let inference_client = diff --git a/paddler_tests/tests/balancer_distributes_embedding_batch_across_agents_with_uneven_slots.rs b/paddler_tests/tests/balancer_distributes_embedding_batch_across_agents_with_uneven_slots.rs new file mode 100644 index 00000000..a00c5ae6 --- /dev/null +++ b/paddler_tests/tests/balancer_distributes_embedding_batch_across_agents_with_uneven_slots.rs @@ -0,0 +1,97 @@ +#![cfg(all( + feature = "tests_that_use_compiled_paddler", + feature = "tests_that_use_llms" +))] + +use std::collections::BTreeSet; + +use anyhow::Result; +use paddler_tests::agent_config::AgentConfig; +use paddler_tests::collect_embedding_results::collect_embedding_results; +use paddler_tests::inference_http_client::InferenceHttpClient; +use paddler_tests::qwen3_embedding_cluster_params::Qwen3EmbeddingClusterParams; +use paddler_tests::start_subprocess_cluster_with_qwen3_embedding::start_subprocess_cluster_with_qwen3_embedding; +use paddler_types::embedding_input_document::EmbeddingInputDocument; +use paddler_types::embedding_normalization_method::EmbeddingNormalizationMethod; +use paddler_types::inference_parameters::InferenceParameters; +use paddler_types::request_params::GenerateEmbeddingBatchParams; +use reqwest::Client; + +#[serial_test::file_serial(model_load, path => "../target/model_load.lock")] +#[tokio::test(flavor = "multi_thread")] +async fn balancer_distributes_embedding_batch_across_agents_with_uneven_slots() -> Result<()> { + let cluster = start_subprocess_cluster_with_qwen3_embedding(Qwen3EmbeddingClusterParams { + agents: vec![ + AgentConfig { + name: "agent-fat".to_owned(), + slot_count: 4, + }, + AgentConfig { + name: "agent-thin-a".to_owned(), + slot_count: 1, + }, + AgentConfig { + name: "agent-medium".to_owned(), + slot_count: 2, + }, + AgentConfig { + name: "agent-thin-b".to_owned(), + slot_count: 1, + }, + ], + inference_parameters: InferenceParameters { + enable_embeddings: true, + ..InferenceParameters::default() + }, + ..Qwen3EmbeddingClusterParams::default() + }) + .await?; + + let inference_client = + InferenceHttpClient::new(Client::new(), cluster.addresses.inference_base_url()?); + + let input_batch: Vec = (0..8) + .map(|index| EmbeddingInputDocument { + content: format!("Uneven-slot document number {index}."), + id: format!("doc-{index}"), + }) + .collect(); + + let stream = inference_client + .post_generate_embedding_batch(&GenerateEmbeddingBatchParams { + input_batch, + normalization_method: EmbeddingNormalizationMethod::None, + }) + .await?; + + let collected = collect_embedding_results(stream).await?; + + assert_eq!(collected.embeddings.len(), 8); + assert!(collected.saw_done); + assert!(collected.errors.is_empty()); + + let returned_document_ids: BTreeSet = collected + .embeddings + .iter() + .map(|produced| produced.embedding.source_document_id.clone()) + .collect(); + let expected_document_ids: BTreeSet = + (0..8).map(|index| format!("doc-{index}")).collect(); + assert_eq!(returned_document_ids, expected_document_ids); + + let producers: BTreeSet<&str> = collected + .embeddings + .iter() + .filter_map(|produced| produced.generated_by.as_deref()) + .collect(); + + assert_eq!( + producers.len(), + 4, + "embedding batch must fan out across all agents even when slot counts are uneven, but only saw producers: {producers:?}", + ); + + cluster.shutdown().await?; + + Ok(()) +} diff --git a/paddler_tests/tests/balancer_distributes_embedding_burst_evenly_across_agents.rs b/paddler_tests/tests/balancer_distributes_embedding_burst_evenly_across_agents.rs index 3ce9c16c..9e114ab5 100644 --- a/paddler_tests/tests/balancer_distributes_embedding_burst_evenly_across_agents.rs +++ b/paddler_tests/tests/balancer_distributes_embedding_burst_evenly_across_agents.rs @@ -5,10 +5,14 @@ use std::collections::BTreeSet; +use std::time::Duration; + use anyhow::Result; use futures_util::future; +use paddler_tests::agent_config::AgentConfig; use paddler_tests::collect_embedding_results::collect_embedding_results; use paddler_tests::inference_http_client::InferenceHttpClient; +use paddler_tests::qwen3_embedding_cluster_params::Qwen3EmbeddingClusterParams; use paddler_tests::start_subprocess_cluster_with_qwen3_embedding::start_subprocess_cluster_with_qwen3_embedding; use paddler_types::embedding_input_document::EmbeddingInputDocument; use paddler_types::embedding_normalization_method::EmbeddingNormalizationMethod; @@ -23,14 +27,15 @@ async fn balancer_distributes_embedding_burst_evenly_across_agents() -> Result<( const SLOTS_PER_AGENT: i32 = 2; const CONCURRENT_REQUESTS: usize = 8; - let cluster = start_subprocess_cluster_with_qwen3_embedding( - InferenceParameters { + let cluster = start_subprocess_cluster_with_qwen3_embedding(Qwen3EmbeddingClusterParams { + agents: AgentConfig::uniform(AGENT_COUNT, SLOTS_PER_AGENT), + buffered_request_timeout: Duration::from_secs(60), + inference_parameters: InferenceParameters { enable_embeddings: true, ..InferenceParameters::default() }, - SLOTS_PER_AGENT, - AGENT_COUNT, - ) + max_buffered_requests: 32, + }) .await?; let inference_client = diff --git a/paddler_tests/tests/balancer_distributes_token_burst_evenly_across_agents.rs b/paddler_tests/tests/balancer_distributes_token_burst_evenly_across_agents.rs index 7e466db5..cf5cded4 100644 --- a/paddler_tests/tests/balancer_distributes_token_burst_evenly_across_agents.rs +++ b/paddler_tests/tests/balancer_distributes_token_burst_evenly_across_agents.rs @@ -8,6 +8,7 @@ use std::collections::BTreeSet; use anyhow::Result; use anyhow::anyhow; use futures_util::future; +use paddler_tests::agent_config::AgentConfig; use paddler_tests::collect_generated_tokens::collect_generated_tokens; use paddler_tests::inference_http_client::InferenceHttpClient; use paddler_tests::start_subprocess_cluster_with_qwen3::start_subprocess_cluster_with_qwen3; @@ -20,7 +21,9 @@ async fn balancer_distributes_token_burst_evenly_across_agents() -> Result<()> { const AGENT_COUNT: usize = 4; const SLOTS_PER_AGENT: i32 = 1; - let cluster = start_subprocess_cluster_with_qwen3(SLOTS_PER_AGENT, AGENT_COUNT).await?; + let cluster = + start_subprocess_cluster_with_qwen3(AgentConfig::uniform(AGENT_COUNT, SLOTS_PER_AGENT)) + .await?; let inference_client = InferenceHttpClient::new(Client::new(), cluster.addresses.inference_base_url()?); diff --git a/paddler_tests/tests/balancer_emits_overflow_errors_when_embedding_burst_exceeds_max_buffered_requests.rs b/paddler_tests/tests/balancer_emits_overflow_errors_when_embedding_burst_exceeds_max_buffered_requests.rs new file mode 100644 index 00000000..a8b42453 --- /dev/null +++ b/paddler_tests/tests/balancer_emits_overflow_errors_when_embedding_burst_exceeds_max_buffered_requests.rs @@ -0,0 +1,98 @@ +#![cfg(all( + feature = "tests_that_use_compiled_paddler", + feature = "tests_that_use_llms" +))] + +use std::time::Duration; + +use anyhow::Result; +use anyhow::anyhow; +use paddler_tests::agent_config::AgentConfig; +use paddler_tests::collect_embedding_results::collect_embedding_results; +use paddler_tests::inference_http_client::InferenceHttpClient; +use paddler_tests::qwen3_embedding_cluster_params::Qwen3EmbeddingClusterParams; +use paddler_tests::start_subprocess_cluster_with_qwen3_embedding::start_subprocess_cluster_with_qwen3_embedding; +use paddler_types::embedding_input_document::EmbeddingInputDocument; +use paddler_types::embedding_normalization_method::EmbeddingNormalizationMethod; +use paddler_types::inference_parameters::InferenceParameters; +use paddler_types::request_params::GenerateEmbeddingBatchParams; +use reqwest::Client; +use tokio::time::timeout; + +#[serial_test::file_serial(model_load, path => "../target/model_load.lock")] +#[tokio::test(flavor = "multi_thread")] +async fn balancer_emits_overflow_errors_when_embedding_burst_exceeds_max_buffered_requests() +-> Result<()> { + const TOTAL_DOCUMENTS: usize = 16; + + let cluster = start_subprocess_cluster_with_qwen3_embedding(Qwen3EmbeddingClusterParams { + agents: AgentConfig::uniform(4, 1), + buffered_request_timeout: Duration::from_secs(2), + inference_parameters: InferenceParameters { + embedding_batch_size: 1, + enable_embeddings: true, + ..InferenceParameters::default() + }, + max_buffered_requests: 4, + }) + .await?; + + let inference_client = + InferenceHttpClient::new(Client::new(), cluster.addresses.inference_base_url()?); + + let input_batch: Vec = (0..TOTAL_DOCUMENTS) + .map(|index| EmbeddingInputDocument { + content: format!("Overflow probe document {index}."), + id: format!("doc-{index}"), + }) + .collect(); + + let stream = inference_client + .post_generate_embedding_batch(&GenerateEmbeddingBatchParams { + input_batch, + normalization_method: EmbeddingNormalizationMethod::None, + }) + .await?; + + let collected = timeout(Duration::from_secs(15), collect_embedding_results(stream)) + .await + .map_err(|_| anyhow!("burst-overflow embedding stream did not finish within 15s"))??; + + let overflow_errors: Vec<_> = collected + .wire_errors + .iter() + .filter(|wire_error| wire_error.code == 503) + .collect(); + + assert!( + !overflow_errors.is_empty(), + "expected at least one HTTP 503 \"Buffered requests overflow\" envelope, but saw none; wire_errors = {:?}", + collected.wire_errors, + ); + + for overflow in &overflow_errors { + assert!( + overflow.description.contains("Buffered requests overflow"), + "expected 503 envelope description to mention overflow, got {:?}", + overflow.description, + ); + } + + assert!( + collected.saw_done, + "stream must terminate cleanly with Done even when some sub-batches overflow", + ); + + assert_eq!( + collected.embeddings.len() + collected.wire_errors.len(), + TOTAL_DOCUMENTS, + "every sub-batch must be accounted for as either a successful embedding or a wire error (503 overflow or 504 timeout): {} embeddings + {} wire errors ({} of which are 503 overflow) ≠ {TOTAL_DOCUMENTS}", + collected.embeddings.len(), + collected.wire_errors.len(), + overflow_errors.len(), + ); + + cluster.shutdown().await?; + + Ok(()) +} diff --git a/paddler_tests/tests/balancer_fans_out_embedding_batch_to_all_agents.rs b/paddler_tests/tests/balancer_fans_out_embedding_batch_to_all_agents.rs index 6da5bdd6..afe3c64f 100644 --- a/paddler_tests/tests/balancer_fans_out_embedding_batch_to_all_agents.rs +++ b/paddler_tests/tests/balancer_fans_out_embedding_batch_to_all_agents.rs @@ -6,8 +6,10 @@ use std::collections::BTreeSet; use anyhow::Result; +use paddler_tests::agent_config::AgentConfig; use paddler_tests::collect_embedding_results::collect_embedding_results; use paddler_tests::inference_http_client::InferenceHttpClient; +use paddler_tests::qwen3_embedding_cluster_params::Qwen3EmbeddingClusterParams; use paddler_tests::start_subprocess_cluster_with_qwen3_embedding::start_subprocess_cluster_with_qwen3_embedding; use paddler_types::embedding_input_document::EmbeddingInputDocument; use paddler_types::embedding_normalization_method::EmbeddingNormalizationMethod; @@ -20,14 +22,14 @@ use reqwest::Client; async fn balancer_fans_out_embedding_batch_to_all_agents() -> Result<()> { let agent_count: usize = 4; - let cluster = start_subprocess_cluster_with_qwen3_embedding( - InferenceParameters { + let cluster = start_subprocess_cluster_with_qwen3_embedding(Qwen3EmbeddingClusterParams { + agents: AgentConfig::uniform(agent_count, 2), + inference_parameters: InferenceParameters { enable_embeddings: true, ..InferenceParameters::default() }, - 2, - agent_count, - ) + ..Qwen3EmbeddingClusterParams::default() + }) .await?; let inference_client = diff --git a/paddler_tests/tests/balancer_in_process_shutdown_with_open_sse_subscriber_completes_within_one_second.rs b/paddler_tests/tests/balancer_in_process_shutdown_with_open_sse_subscriber_completes_within_one_second.rs index 9c9d54bb..0fa01396 100644 --- a/paddler_tests/tests/balancer_in_process_shutdown_with_open_sse_subscriber_completes_within_one_second.rs +++ b/paddler_tests/tests/balancer_in_process_shutdown_with_open_sse_subscriber_completes_within_one_second.rs @@ -11,7 +11,7 @@ use tokio::time::timeout; async fn balancer_in_process_shutdown_with_open_sse_subscriber_completes_within_one_second() -> Result<()> { let cluster = start_in_process_cluster(InProcessClusterParams { - spawn_agent: false, + agent: None, wait_for_slots_ready: false, ..InProcessClusterParams::default() }) diff --git a/paddler_tests/tests/balancer_inference_health_returns_ok.rs b/paddler_tests/tests/balancer_inference_health_returns_ok.rs index 49f0f1ef..f25ff347 100644 --- a/paddler_tests/tests/balancer_inference_health_returns_ok.rs +++ b/paddler_tests/tests/balancer_inference_health_returns_ok.rs @@ -8,7 +8,7 @@ use paddler_tests::subprocess_cluster_params::SubprocessClusterParams; #[tokio::test(flavor = "multi_thread")] async fn balancer_inference_health_returns_ok() -> Result<()> { let cluster = start_subprocess_cluster(SubprocessClusterParams { - agent_count: 0, + agents: Vec::new(), wait_for_slots_ready: false, ..SubprocessClusterParams::default() }) diff --git a/paddler_tests/tests/balancer_inference_service_replies_with_configured_cors_origin.rs b/paddler_tests/tests/balancer_inference_service_replies_with_configured_cors_origin.rs index 164457d7..68cc3b26 100644 --- a/paddler_tests/tests/balancer_inference_service_replies_with_configured_cors_origin.rs +++ b/paddler_tests/tests/balancer_inference_service_replies_with_configured_cors_origin.rs @@ -10,7 +10,7 @@ const ALLOWED_ORIGIN: &str = "http://example.com"; #[tokio::test(flavor = "multi_thread")] async fn balancer_inference_service_replies_with_configured_cors_origin() -> Result<()> { let cluster = start_subprocess_cluster(SubprocessClusterParams { - agent_count: 0, + agents: Vec::new(), inference_cors_allowed_hosts: vec![ALLOWED_ORIGIN.to_owned()], wait_for_slots_ready: false, ..SubprocessClusterParams::default() diff --git a/paddler_tests/tests/balancer_management_service_replies_with_configured_cors_origin.rs b/paddler_tests/tests/balancer_management_service_replies_with_configured_cors_origin.rs index cf166dbc..003b4993 100644 --- a/paddler_tests/tests/balancer_management_service_replies_with_configured_cors_origin.rs +++ b/paddler_tests/tests/balancer_management_service_replies_with_configured_cors_origin.rs @@ -10,7 +10,7 @@ const ALLOWED_ORIGIN: &str = "http://example.com"; #[tokio::test(flavor = "multi_thread")] async fn balancer_management_service_replies_with_configured_cors_origin() -> Result<()> { let cluster = start_subprocess_cluster(SubprocessClusterParams { - agent_count: 0, + agents: Vec::new(), management_cors_allowed_hosts: vec![ALLOWED_ORIGIN.to_owned()], wait_for_slots_ready: false, ..SubprocessClusterParams::default() diff --git a/paddler_tests/tests/balancer_memory_storage_persists_desired_state.rs b/paddler_tests/tests/balancer_memory_storage_persists_desired_state.rs index cc6065ec..2a99f0ec 100644 --- a/paddler_tests/tests/balancer_memory_storage_persists_desired_state.rs +++ b/paddler_tests/tests/balancer_memory_storage_persists_desired_state.rs @@ -27,7 +27,7 @@ async fn balancer_memory_storage_persists_desired_state() -> Result<()> { }; let cluster = start_subprocess_cluster(SubprocessClusterParams { - agent_count: 0, + agents: Vec::new(), wait_for_slots_ready: false, state_database_url: "memory://".to_owned(), desired_state: Some(desired_state.clone()), diff --git a/paddler_tests/tests/balancer_openai_compat_health_returns_ok.rs b/paddler_tests/tests/balancer_openai_compat_health_returns_ok.rs index ed9d49b1..0c5876c5 100644 --- a/paddler_tests/tests/balancer_openai_compat_health_returns_ok.rs +++ b/paddler_tests/tests/balancer_openai_compat_health_returns_ok.rs @@ -8,7 +8,7 @@ use paddler_tests::subprocess_cluster_params::SubprocessClusterParams; #[tokio::test(flavor = "multi_thread")] async fn balancer_openai_compat_health_returns_ok() -> Result<()> { let cluster = start_subprocess_cluster(SubprocessClusterParams { - agent_count: 0, + agents: Vec::new(), wait_for_slots_ready: false, ..SubprocessClusterParams::default() }) diff --git a/paddler_tests/tests/balancer_persists_chat_template_override_across_restart.rs b/paddler_tests/tests/balancer_persists_chat_template_override_across_restart.rs index a8d67c5f..7452f91e 100644 --- a/paddler_tests/tests/balancer_persists_chat_template_override_across_restart.rs +++ b/paddler_tests/tests/balancer_persists_chat_template_override_across_restart.rs @@ -35,7 +35,7 @@ async fn balancer_persists_chat_template_override_across_restart() -> Result<()> }; let first_cluster = start_subprocess_cluster(SubprocessClusterParams { - agent_count: 0, + agents: Vec::new(), wait_for_slots_ready: false, state_database_url: database.url.clone(), desired_state: Some(desired_state.clone()), @@ -46,7 +46,7 @@ async fn balancer_persists_chat_template_override_across_restart() -> Result<()> first_cluster.shutdown().await?; let second_cluster = start_subprocess_cluster(SubprocessClusterParams { - agent_count: 0, + agents: Vec::new(), wait_for_slots_ready: false, state_database_url: database.url.clone(), desired_state: None, diff --git a/paddler_tests/tests/balancer_persists_desired_state_across_restart.rs b/paddler_tests/tests/balancer_persists_desired_state_across_restart.rs index bb5675e6..278f3f25 100644 --- a/paddler_tests/tests/balancer_persists_desired_state_across_restart.rs +++ b/paddler_tests/tests/balancer_persists_desired_state_across_restart.rs @@ -30,7 +30,7 @@ async fn balancer_persists_desired_state_across_restart() -> Result<()> { }; let first_cluster = start_subprocess_cluster(SubprocessClusterParams { - agent_count: 0, + agents: Vec::new(), wait_for_slots_ready: false, state_database_url: database.url.clone(), desired_state: Some(desired_state.clone()), @@ -41,7 +41,7 @@ async fn balancer_persists_desired_state_across_restart() -> Result<()> { first_cluster.shutdown().await?; let second_cluster = start_subprocess_cluster(SubprocessClusterParams { - agent_count: 0, + agents: Vec::new(), wait_for_slots_ready: false, state_database_url: database.url.clone(), desired_state: None, diff --git a/paddler_tests/tests/balancer_persists_huggingface_mmproj_in_desired_state.rs b/paddler_tests/tests/balancer_persists_huggingface_mmproj_in_desired_state.rs index bd96cd33..38b2fb26 100644 --- a/paddler_tests/tests/balancer_persists_huggingface_mmproj_in_desired_state.rs +++ b/paddler_tests/tests/balancer_persists_huggingface_mmproj_in_desired_state.rs @@ -27,7 +27,7 @@ async fn balancer_persists_huggingface_mmproj_in_desired_state() -> Result<()> { } = smolvlm2_256m_mmproj(); let cluster = start_subprocess_cluster(SubprocessClusterParams { - agent_count: 0, + agents: Vec::new(), wait_for_slots_ready: false, desired_state: Some(BalancerDesiredState { chat_template_override: None, diff --git a/paddler_tests/tests/balancer_persists_local_mmproj_path_in_desired_state.rs b/paddler_tests/tests/balancer_persists_local_mmproj_path_in_desired_state.rs index 08ca0fca..db0ba63a 100644 --- a/paddler_tests/tests/balancer_persists_local_mmproj_path_in_desired_state.rs +++ b/paddler_tests/tests/balancer_persists_local_mmproj_path_in_desired_state.rs @@ -21,7 +21,7 @@ async fn balancer_persists_local_mmproj_path_in_desired_state() -> Result<()> { let local_mmproj_path = "/tmp/test-mmproj.gguf".to_owned(); let cluster = start_subprocess_cluster(SubprocessClusterParams { - agent_count: 0, + agents: Vec::new(), wait_for_slots_ready: false, desired_state: Some(BalancerDesiredState { chat_template_override: None, diff --git a/paddler_tests/tests/balancer_persists_model_switch_in_storage.rs b/paddler_tests/tests/balancer_persists_model_switch_in_storage.rs index 7f3cba23..f69a1133 100644 --- a/paddler_tests/tests/balancer_persists_model_switch_in_storage.rs +++ b/paddler_tests/tests/balancer_persists_model_switch_in_storage.rs @@ -30,7 +30,7 @@ async fn balancer_persists_model_switch_in_storage() -> Result<()> { }; let cluster = start_subprocess_cluster(SubprocessClusterParams { - agent_count: 0, + agents: Vec::new(), wait_for_slots_ready: false, state_database_url: database.url.clone(), desired_state: Some(initial_state.clone()), diff --git a/paddler_tests/tests/balancer_registers_multiple_agents_over_time.rs b/paddler_tests/tests/balancer_registers_multiple_agents_over_time.rs index 7e2171df..d86bc932 100644 --- a/paddler_tests/tests/balancer_registers_multiple_agents_over_time.rs +++ b/paddler_tests/tests/balancer_registers_multiple_agents_over_time.rs @@ -11,7 +11,7 @@ use paddler_tests::subprocess_cluster_params::SubprocessClusterParams; #[tokio::test(flavor = "multi_thread")] async fn balancer_registers_multiple_agents_over_time() -> Result<()> { let mut cluster = start_subprocess_cluster(SubprocessClusterParams { - agent_count: 0, + agents: Vec::new(), wait_for_slots_ready: false, ..SubprocessClusterParams::default() }) diff --git a/paddler_tests/tests/balancer_reports_chat_template_does_not_compile_for_invalid_jinja.rs b/paddler_tests/tests/balancer_reports_chat_template_does_not_compile_for_invalid_jinja.rs index 907c3543..709af576 100644 --- a/paddler_tests/tests/balancer_reports_chat_template_does_not_compile_for_invalid_jinja.rs +++ b/paddler_tests/tests/balancer_reports_chat_template_does_not_compile_for_invalid_jinja.rs @@ -5,6 +5,7 @@ use anyhow::Context as _; use anyhow::Result; +use paddler_tests::agent_config::AgentConfig; use paddler_tests::model_card::ModelCard; use paddler_tests::model_card::qwen3_0_6b::qwen3_0_6b; use paddler_tests::start_subprocess_cluster::start_subprocess_cluster; @@ -21,8 +22,7 @@ async fn balancer_reports_chat_template_does_not_compile_for_invalid_jinja() -> let ModelCard { reference, .. } = qwen3_0_6b(); let mut cluster = start_subprocess_cluster(SubprocessClusterParams { - agent_count: 1, - slots_per_agent: 1, + agents: AgentConfig::uniform(1, 1), wait_for_slots_ready: false, desired_state: Some(BalancerDesiredState { chat_template_override: Some(ChatTemplate { diff --git a/paddler_tests/tests/balancer_reports_huggingface_model_does_not_exist.rs b/paddler_tests/tests/balancer_reports_huggingface_model_does_not_exist.rs index 1283fcad..411452b1 100644 --- a/paddler_tests/tests/balancer_reports_huggingface_model_does_not_exist.rs +++ b/paddler_tests/tests/balancer_reports_huggingface_model_does_not_exist.rs @@ -5,6 +5,7 @@ use anyhow::Context as _; use anyhow::Result; +use paddler_tests::agent_config::AgentConfig; use paddler_tests::start_subprocess_cluster::start_subprocess_cluster; use paddler_tests::subprocess_cluster_params::SubprocessClusterParams; use paddler_types::agent_desired_model::AgentDesiredModel; @@ -17,8 +18,7 @@ use paddler_types::inference_parameters::InferenceParameters; #[tokio::test(flavor = "multi_thread")] async fn balancer_reports_huggingface_model_does_not_exist() -> Result<()> { let mut cluster = start_subprocess_cluster(SubprocessClusterParams { - agent_count: 1, - slots_per_agent: 1, + agents: AgentConfig::uniform(1, 1), wait_for_slots_ready: false, desired_state: Some(BalancerDesiredState { chat_template_override: None, diff --git a/paddler_tests/tests/balancer_reports_mmproj_cannot_be_loaded_for_invalid_file.rs b/paddler_tests/tests/balancer_reports_mmproj_cannot_be_loaded_for_invalid_file.rs index c2059079..8e75911c 100644 --- a/paddler_tests/tests/balancer_reports_mmproj_cannot_be_loaded_for_invalid_file.rs +++ b/paddler_tests/tests/balancer_reports_mmproj_cannot_be_loaded_for_invalid_file.rs @@ -5,6 +5,7 @@ use anyhow::Context as _; use anyhow::Result; +use paddler_tests::agent_config::AgentConfig; use paddler_tests::model_card::ModelCard; use paddler_tests::model_card::qwen3_0_6b::qwen3_0_6b; use paddler_tests::start_subprocess_cluster::start_subprocess_cluster; @@ -25,8 +26,7 @@ async fn balancer_reports_mmproj_cannot_be_loaded_for_invalid_file() -> Result<( let ModelCard { reference, .. } = qwen3_0_6b(); let mut cluster = start_subprocess_cluster(SubprocessClusterParams { - agent_count: 1, - slots_per_agent: 1, + agents: AgentConfig::uniform(1, 1), wait_for_slots_ready: false, desired_state: Some(BalancerDesiredState { chat_template_override: None, diff --git a/paddler_tests/tests/balancer_reports_model_cannot_be_loaded_for_corrupt_file.rs b/paddler_tests/tests/balancer_reports_model_cannot_be_loaded_for_corrupt_file.rs index 5c6e94e8..0c74809d 100644 --- a/paddler_tests/tests/balancer_reports_model_cannot_be_loaded_for_corrupt_file.rs +++ b/paddler_tests/tests/balancer_reports_model_cannot_be_loaded_for_corrupt_file.rs @@ -7,6 +7,7 @@ use std::io::Write as _; use anyhow::Context as _; use anyhow::Result; +use paddler_tests::agent_config::AgentConfig; use paddler_tests::start_subprocess_cluster::start_subprocess_cluster; use paddler_tests::subprocess_cluster_params::SubprocessClusterParams; use paddler_types::agent_desired_model::AgentDesiredModel; @@ -29,8 +30,7 @@ async fn balancer_reports_model_cannot_be_loaded_for_corrupt_file() -> Result<() .to_owned(); let mut cluster = start_subprocess_cluster(SubprocessClusterParams { - agent_count: 1, - slots_per_agent: 1, + agents: AgentConfig::uniform(1, 1), wait_for_slots_ready: false, desired_state: Some(BalancerDesiredState { chat_template_override: None, diff --git a/paddler_tests/tests/balancer_reports_model_cannot_be_loaded_for_invalid_gguf.rs b/paddler_tests/tests/balancer_reports_model_cannot_be_loaded_for_invalid_gguf.rs index 2167da44..000db231 100644 --- a/paddler_tests/tests/balancer_reports_model_cannot_be_loaded_for_invalid_gguf.rs +++ b/paddler_tests/tests/balancer_reports_model_cannot_be_loaded_for_invalid_gguf.rs @@ -5,6 +5,7 @@ use anyhow::Context as _; use anyhow::Result; +use paddler_tests::agent_config::AgentConfig; use paddler_tests::start_subprocess_cluster::start_subprocess_cluster; use paddler_tests::subprocess_cluster_params::SubprocessClusterParams; use paddler_types::agent_desired_model::AgentDesiredModel; @@ -18,8 +19,7 @@ async fn balancer_reports_model_cannot_be_loaded_for_invalid_gguf() -> Result<() let invalid_gguf_path = concat!(env!("CARGO_MANIFEST_DIR"), "/../fixtures/invalid.gguf"); let mut cluster = start_subprocess_cluster(SubprocessClusterParams { - agent_count: 1, - slots_per_agent: 1, + agents: AgentConfig::uniform(1, 1), wait_for_slots_ready: false, desired_state: Some(BalancerDesiredState { chat_template_override: None, diff --git a/paddler_tests/tests/balancer_reports_model_file_does_not_exist.rs b/paddler_tests/tests/balancer_reports_model_file_does_not_exist.rs index fd8303c7..f84aad13 100644 --- a/paddler_tests/tests/balancer_reports_model_file_does_not_exist.rs +++ b/paddler_tests/tests/balancer_reports_model_file_does_not_exist.rs @@ -5,6 +5,7 @@ use anyhow::Context as _; use anyhow::Result; +use paddler_tests::agent_config::AgentConfig; use paddler_tests::start_subprocess_cluster::start_subprocess_cluster; use paddler_tests::subprocess_cluster_params::SubprocessClusterParams; use paddler_types::agent_desired_model::AgentDesiredModel; @@ -16,8 +17,7 @@ use paddler_types::inference_parameters::InferenceParameters; #[tokio::test(flavor = "multi_thread")] async fn balancer_reports_model_file_does_not_exist() -> Result<()> { let mut cluster = start_subprocess_cluster(SubprocessClusterParams { - agent_count: 1, - slots_per_agent: 1, + agents: AgentConfig::uniform(1, 1), wait_for_slots_ready: false, desired_state: Some(BalancerDesiredState { chat_template_override: None, diff --git a/paddler_tests/tests/balancer_reports_multimodal_projection_cannot_be_loaded.rs b/paddler_tests/tests/balancer_reports_multimodal_projection_cannot_be_loaded.rs index d50caeaa..261a88fe 100644 --- a/paddler_tests/tests/balancer_reports_multimodal_projection_cannot_be_loaded.rs +++ b/paddler_tests/tests/balancer_reports_multimodal_projection_cannot_be_loaded.rs @@ -5,6 +5,7 @@ use anyhow::Context as _; use anyhow::Result; +use paddler_tests::agent_config::AgentConfig; use paddler_tests::model_card::ModelCard; use paddler_tests::model_card::qwen3_0_6b::qwen3_0_6b; use paddler_tests::start_subprocess_cluster::start_subprocess_cluster; @@ -20,8 +21,7 @@ async fn balancer_reports_multimodal_projection_cannot_be_loaded() -> Result<()> let ModelCard { reference, .. } = qwen3_0_6b(); let mut cluster = start_subprocess_cluster(SubprocessClusterParams { - agent_count: 1, - slots_per_agent: 1, + agents: AgentConfig::uniform(1, 1), wait_for_slots_ready: false, desired_state: Some(BalancerDesiredState { chat_template_override: None, diff --git a/paddler_tests/tests/balancer_reports_unable_to_find_chat_template_for_embedding_model.rs b/paddler_tests/tests/balancer_reports_unable_to_find_chat_template_for_embedding_model.rs index 33d90d74..bbabf31f 100644 --- a/paddler_tests/tests/balancer_reports_unable_to_find_chat_template_for_embedding_model.rs +++ b/paddler_tests/tests/balancer_reports_unable_to_find_chat_template_for_embedding_model.rs @@ -5,6 +5,7 @@ use anyhow::Context as _; use anyhow::Result; +use paddler_tests::agent_config::AgentConfig; use paddler_tests::model_card::ModelCard; use paddler_tests::model_card::nomic_embed_text_v1_5::nomic_embed_text_v1_5; use paddler_tests::start_subprocess_cluster::start_subprocess_cluster; @@ -20,8 +21,7 @@ async fn balancer_reports_unable_to_find_chat_template_for_embedding_model() -> let ModelCard { reference, .. } = nomic_embed_text_v1_5(); let mut cluster = start_subprocess_cluster(SubprocessClusterParams { - agent_count: 1, - slots_per_agent: 1, + agents: AgentConfig::uniform(1, 1), wait_for_slots_ready: false, desired_state: Some(BalancerDesiredState { chat_template_override: None, diff --git a/paddler_tests/tests/balancer_resolves_buffered_requests_after_agent_killed.rs b/paddler_tests/tests/balancer_resolves_buffered_requests_after_agent_killed.rs index c0bbc611..8978d19b 100644 --- a/paddler_tests/tests/balancer_resolves_buffered_requests_after_agent_killed.rs +++ b/paddler_tests/tests/balancer_resolves_buffered_requests_after_agent_killed.rs @@ -8,6 +8,7 @@ use std::time::Duration; use anyhow::Context as _; use anyhow::Result; use futures_util::StreamExt as _; +use paddler_tests::agent_config::AgentConfig; use paddler_tests::current_test_device::current_test_device; use paddler_tests::inference_http_client::InferenceHttpClient; use paddler_tests::model_card::ModelCard; @@ -35,12 +36,13 @@ async fn balancer_resolves_buffered_requests_after_agent_killed() -> Result<()> } = qwen3_0_6b(); let mut cluster = start_subprocess_cluster(SubprocessClusterParams { - agent_count: 1, - slots_per_agent: 2, + agents: vec![AgentConfig { + name: "removal-agent-primary".to_owned(), + slot_count: 2, + }], wait_for_slots_ready: true, buffered_request_timeout: Duration::from_secs(120), max_buffered_requests: 10, - agent_name_prefix: "removal-agent-primary".to_owned(), desired_state: Some(BalancerDesiredState { chat_template_override: None, inference_parameters: device.inference_parameters_for_full_offload(gpu_layer_count), diff --git a/paddler_tests/tests/balancer_returns_503_when_request_buffering_disabled.rs b/paddler_tests/tests/balancer_returns_503_when_request_buffering_disabled.rs index b14301b0..3d661ad0 100644 --- a/paddler_tests/tests/balancer_returns_503_when_request_buffering_disabled.rs +++ b/paddler_tests/tests/balancer_returns_503_when_request_buffering_disabled.rs @@ -21,7 +21,7 @@ use reqwest::Client; #[tokio::test(flavor = "multi_thread")] async fn balancer_returns_503_when_request_buffering_disabled() -> Result<()> { let cluster = start_subprocess_cluster(SubprocessClusterParams { - agent_count: 0, + agents: Vec::new(), wait_for_slots_ready: false, buffered_request_timeout: Duration::from_millis(50), max_buffered_requests: 0, diff --git a/paddler_tests/tests/balancer_returns_504_when_inference_item_timeout_is_zero.rs b/paddler_tests/tests/balancer_returns_504_when_inference_item_timeout_is_zero.rs index a3e7068d..9e7555ea 100644 --- a/paddler_tests/tests/balancer_returns_504_when_inference_item_timeout_is_zero.rs +++ b/paddler_tests/tests/balancer_returns_504_when_inference_item_timeout_is_zero.rs @@ -8,6 +8,7 @@ use std::time::Duration; use anyhow::Context as _; use anyhow::Result; use futures_util::StreamExt as _; +use paddler_tests::agent_config::AgentConfig; use paddler_tests::current_test_device::current_test_device; use paddler_tests::inference_http_client::InferenceHttpClient; use paddler_tests::model_card::ModelCard; @@ -33,8 +34,7 @@ async fn balancer_returns_504_when_inference_item_timeout_is_zero() -> Result<() } = qwen3_0_6b(); let cluster = start_subprocess_cluster(SubprocessClusterParams { - agent_count: 1, - slots_per_agent: 2, + agents: AgentConfig::uniform(1, 2), inference_item_timeout: Duration::ZERO, wait_for_slots_ready: true, desired_state: Some(BalancerDesiredState { diff --git a/paddler_tests/tests/balancer_returns_504_when_no_agents_registered.rs b/paddler_tests/tests/balancer_returns_504_when_no_agents_registered.rs index 85f13099..9a3071b5 100644 --- a/paddler_tests/tests/balancer_returns_504_when_no_agents_registered.rs +++ b/paddler_tests/tests/balancer_returns_504_when_no_agents_registered.rs @@ -26,7 +26,7 @@ async fn balancer_returns_504_when_no_agents_registered() -> Result<()> { let ModelCard { reference, .. } = qwen3_0_6b(); let cluster = start_subprocess_cluster(SubprocessClusterParams { - agent_count: 0, + agents: Vec::new(), wait_for_slots_ready: false, buffered_request_timeout: Duration::from_millis(50), max_buffered_requests: 1, diff --git a/paddler_tests/tests/balancer_returns_504_when_no_model_configured.rs b/paddler_tests/tests/balancer_returns_504_when_no_model_configured.rs index 24dc75af..041910ce 100644 --- a/paddler_tests/tests/balancer_returns_504_when_no_model_configured.rs +++ b/paddler_tests/tests/balancer_returns_504_when_no_model_configured.rs @@ -13,7 +13,7 @@ use reqwest::Client; #[tokio::test(flavor = "multi_thread")] async fn balancer_returns_504_when_no_model_configured() -> Result<()> { let cluster = start_subprocess_cluster(SubprocessClusterParams { - agent_count: 0, + agents: Vec::new(), wait_for_slots_ready: false, ..SubprocessClusterParams::default() }) diff --git a/paddler_tests/tests/balancer_serves_request_after_agent_with_capacity_registers.rs b/paddler_tests/tests/balancer_serves_request_after_agent_with_capacity_registers.rs index bdc9e57a..e584be7d 100644 --- a/paddler_tests/tests/balancer_serves_request_after_agent_with_capacity_registers.rs +++ b/paddler_tests/tests/balancer_serves_request_after_agent_with_capacity_registers.rs @@ -35,7 +35,7 @@ async fn balancer_serves_request_after_agent_with_capacity_registers() -> Result } = qwen3_0_6b(); let mut cluster = start_subprocess_cluster(SubprocessClusterParams { - agent_count: 0, + agents: Vec::new(), wait_for_slots_ready: false, buffered_request_timeout: Duration::from_millis(50), max_buffered_requests: 10, diff --git a/paddler_tests/tests/chat_template_drains_in_flight_inference_before_swap.rs b/paddler_tests/tests/chat_template_drains_in_flight_inference_before_swap.rs index ccbf664f..ab8c85fa 100644 --- a/paddler_tests/tests/chat_template_drains_in_flight_inference_before_swap.rs +++ b/paddler_tests/tests/chat_template_drains_in_flight_inference_before_swap.rs @@ -5,6 +5,7 @@ use anyhow::Context as _; use anyhow::Result; +use paddler_tests::agent_config::AgentConfig; use paddler_tests::collect_generated_tokens::collect_generated_tokens; use paddler_tests::current_test_device::current_test_device; use paddler_tests::inference_http_client::InferenceHttpClient; @@ -43,8 +44,7 @@ async fn chat_template_drains_in_flight_inference_before_swap() -> Result<()> { }; let cluster = start_subprocess_cluster(SubprocessClusterParams { - agent_count: 1, - slots_per_agent: 1, + agents: AgentConfig::uniform(1, 1), wait_for_slots_ready: true, desired_state: Some(BalancerDesiredState { chat_template_override: Some(template_a.clone()), diff --git a/paddler_tests/tests/chat_template_override_applied_to_embedding_model.rs b/paddler_tests/tests/chat_template_override_applied_to_embedding_model.rs index 73b97a7b..3f8cf97b 100644 --- a/paddler_tests/tests/chat_template_override_applied_to_embedding_model.rs +++ b/paddler_tests/tests/chat_template_override_applied_to_embedding_model.rs @@ -5,6 +5,7 @@ use anyhow::Context as _; use anyhow::Result; +use paddler_tests::agent_config::AgentConfig; use paddler_tests::model_card::ModelCard; use paddler_tests::model_card::nomic_embed_text_v1_5::nomic_embed_text_v1_5; use paddler_tests::start_subprocess_cluster::start_subprocess_cluster; @@ -24,8 +25,7 @@ async fn chat_template_override_applied_to_embedding_model() -> Result<()> { }; let cluster = start_subprocess_cluster(SubprocessClusterParams { - agent_count: 1, - slots_per_agent: 1, + agents: AgentConfig::uniform(1, 1), wait_for_slots_ready: false, desired_state: Some(BalancerDesiredState { chat_template_override: Some(chat_template.clone()), diff --git a/paddler_tests/tests/chat_template_override_replaces_model_builtin.rs b/paddler_tests/tests/chat_template_override_replaces_model_builtin.rs index 00b87bdf..f759a776 100644 --- a/paddler_tests/tests/chat_template_override_replaces_model_builtin.rs +++ b/paddler_tests/tests/chat_template_override_replaces_model_builtin.rs @@ -5,6 +5,7 @@ use anyhow::Context as _; use anyhow::Result; +use paddler_tests::agent_config::AgentConfig; use paddler_tests::collect_generated_tokens::collect_generated_tokens; use paddler_tests::current_test_device::current_test_device; use paddler_tests::inference_http_client::InferenceHttpClient; @@ -38,8 +39,7 @@ async fn chat_template_override_replaces_model_builtin() -> Result<()> { }; let cluster = start_subprocess_cluster(SubprocessClusterParams { - agent_count: 1, - slots_per_agent: 1, + agents: AgentConfig::uniform(1, 1), wait_for_slots_ready: true, desired_state: Some(BalancerDesiredState { chat_template_override: Some(chat_template.clone()), diff --git a/paddler_tests/tests/chat_template_swaps_between_inference_calls.rs b/paddler_tests/tests/chat_template_swaps_between_inference_calls.rs index 38696ecf..fcc7fa72 100644 --- a/paddler_tests/tests/chat_template_swaps_between_inference_calls.rs +++ b/paddler_tests/tests/chat_template_swaps_between_inference_calls.rs @@ -5,6 +5,7 @@ use anyhow::Context as _; use anyhow::Result; +use paddler_tests::agent_config::AgentConfig; use paddler_tests::collect_generated_tokens::collect_generated_tokens; use paddler_tests::current_test_device::current_test_device; use paddler_tests::inference_http_client::InferenceHttpClient; @@ -65,8 +66,7 @@ async fn chat_template_swaps_between_inference_calls() -> Result<()> { }; let cluster = start_subprocess_cluster(SubprocessClusterParams { - agent_count: 1, - slots_per_agent: 1, + agents: AgentConfig::uniform(1, 1), wait_for_slots_ready: true, desired_state: Some(BalancerDesiredState { chat_template_override: Some(template_a.clone()), diff --git a/paddler_tests/tests/continuous_batch_concurrent_conversation_history_requests_complete.rs b/paddler_tests/tests/continuous_batch_concurrent_conversation_history_requests_complete.rs index 84a57d7e..971f38e6 100644 --- a/paddler_tests/tests/continuous_batch_concurrent_conversation_history_requests_complete.rs +++ b/paddler_tests/tests/continuous_batch_concurrent_conversation_history_requests_complete.rs @@ -1,6 +1,7 @@ #![cfg(feature = "tests_that_use_llms")] use anyhow::Result; +use paddler_tests::agent_config::AgentConfig; use paddler_tests::collect_generated_tokens::collect_generated_tokens; use paddler_tests::inference_http_client::InferenceHttpClient; use paddler_tests::start_in_process_cluster_with_qwen3::start_in_process_cluster_with_qwen3; @@ -22,7 +23,7 @@ fn user_message(text: &str) -> ConversationMessage { #[serial_test::file_serial(model_load, path => "../target/model_load.lock")] #[tokio::test(flavor = "multi_thread")] async fn continuous_batch_concurrent_conversation_history_requests_complete() -> Result<()> { - let cluster = start_in_process_cluster_with_qwen3(2).await?; + let cluster = start_in_process_cluster_with_qwen3(AgentConfig::single(2)).await?; let inference_client = InferenceHttpClient::new(Client::new(), cluster.addresses.inference_base_url()?); diff --git a/paddler_tests/tests/continuous_batch_distinct_output.rs b/paddler_tests/tests/continuous_batch_distinct_output.rs index e5c1c2a6..9c4d43ef 100644 --- a/paddler_tests/tests/continuous_batch_distinct_output.rs +++ b/paddler_tests/tests/continuous_batch_distinct_output.rs @@ -1,6 +1,7 @@ #![cfg(feature = "tests_that_use_llms")] use anyhow::Result; +use paddler_tests::agent_config::AgentConfig; use paddler_tests::collect_generated_tokens::collect_generated_tokens; use paddler_tests::current_test_device::current_test_device; use paddler_tests::in_process_cluster_params::InProcessClusterParams; @@ -34,8 +35,10 @@ async fn two_concurrent_prompts_produce_distinct_outputs() -> Result<()> { }; let cluster = start_in_process_cluster(InProcessClusterParams { - spawn_agent: true, - slots_per_agent: 2, + agent: Some(AgentConfig { + name: "test-agent".to_owned(), + slot_count: 2, + }), desired_state, wait_for_slots_ready: true, ..InProcessClusterParams::default() diff --git a/paddler_tests/tests/continuous_batch_evicts_long_sequence_under_kv_pressure.rs b/paddler_tests/tests/continuous_batch_evicts_long_sequence_under_kv_pressure.rs index bbf69981..1d5e3115 100644 --- a/paddler_tests/tests/continuous_batch_evicts_long_sequence_under_kv_pressure.rs +++ b/paddler_tests/tests/continuous_batch_evicts_long_sequence_under_kv_pressure.rs @@ -1,6 +1,7 @@ #![cfg(feature = "tests_that_use_llms")] use anyhow::Result; +use paddler_tests::agent_config::AgentConfig; use paddler_tests::collect_generated_tokens::collect_generated_tokens; use paddler_tests::current_test_device::current_test_device; use paddler_tests::in_process_cluster_params::InProcessClusterParams; @@ -34,8 +35,10 @@ async fn continuous_batch_evicts_long_sequence_under_kv_pressure() -> Result<()> inference_parameters.temperature = 0.0; let cluster = start_in_process_cluster(InProcessClusterParams { - spawn_agent: true, - slots_per_agent: 2, + agent: Some(AgentConfig { + name: "test-agent".to_owned(), + slot_count: 2, + }), desired_state: BalancerDesiredState { chat_template_override: None, inference_parameters, diff --git a/paddler_tests/tests/continuous_batch_generates_tokens_with_distinct_k_and_v_cache_dtypes.rs b/paddler_tests/tests/continuous_batch_generates_tokens_with_distinct_k_and_v_cache_dtypes.rs index 041d5f3b..e5fc6a90 100644 --- a/paddler_tests/tests/continuous_batch_generates_tokens_with_distinct_k_and_v_cache_dtypes.rs +++ b/paddler_tests/tests/continuous_batch_generates_tokens_with_distinct_k_and_v_cache_dtypes.rs @@ -1,6 +1,7 @@ #![cfg(feature = "tests_that_use_llms")] use anyhow::Result; +use paddler_tests::agent_config::AgentConfig; use paddler_tests::collect_generated_tokens::collect_generated_tokens; use paddler_tests::current_test_device::current_test_device; use paddler_tests::in_process_cluster_params::InProcessClusterParams; @@ -34,8 +35,10 @@ async fn continuous_batch_generates_tokens_with_distinct_k_and_v_cache_dtypes() inference_parameters.v_cache_dtype = KvCacheDtype::F16; let cluster = start_in_process_cluster(InProcessClusterParams { - spawn_agent: true, - slots_per_agent: 1, + agent: Some(AgentConfig { + name: "test-agent".to_owned(), + slot_count: 1, + }), desired_state: BalancerDesiredState { chat_template_override: None, inference_parameters, diff --git a/paddler_tests/tests/continuous_batch_generates_tokens_with_partial_layer_offload.rs b/paddler_tests/tests/continuous_batch_generates_tokens_with_partial_layer_offload.rs index eadc64ff..20227f34 100644 --- a/paddler_tests/tests/continuous_batch_generates_tokens_with_partial_layer_offload.rs +++ b/paddler_tests/tests/continuous_batch_generates_tokens_with_partial_layer_offload.rs @@ -1,6 +1,7 @@ #![cfg(feature = "tests_that_use_llms")] use anyhow::Result; +use paddler_tests::agent_config::AgentConfig; use paddler_tests::collect_generated_tokens::collect_generated_tokens; use paddler_tests::current_test_device::current_test_device; use paddler_tests::in_process_cluster_params::InProcessClusterParams; @@ -30,8 +31,10 @@ async fn continuous_batch_generates_tokens_with_partial_layer_offload() -> Resul device.inference_parameters_for_full_offload(PARTIAL_GPU_LAYER_COUNT); let cluster = start_in_process_cluster(InProcessClusterParams { - spawn_agent: true, - slots_per_agent: 1, + agent: Some(AgentConfig { + name: "test-agent".to_owned(), + slot_count: 1, + }), desired_state: BalancerDesiredState { chat_template_override: None, inference_parameters, diff --git a/paddler_tests/tests/continuous_batch_long_and_short_prompts_complete_concurrently.rs b/paddler_tests/tests/continuous_batch_long_and_short_prompts_complete_concurrently.rs index 09b6ad53..a6f37ab7 100644 --- a/paddler_tests/tests/continuous_batch_long_and_short_prompts_complete_concurrently.rs +++ b/paddler_tests/tests/continuous_batch_long_and_short_prompts_complete_concurrently.rs @@ -1,6 +1,7 @@ #![cfg(feature = "tests_that_use_llms")] use anyhow::Result; +use paddler_tests::agent_config::AgentConfig; use paddler_tests::collect_generated_tokens::collect_generated_tokens; use paddler_tests::inference_http_client::InferenceHttpClient; use paddler_tests::start_in_process_cluster_with_qwen3::start_in_process_cluster_with_qwen3; @@ -12,7 +13,7 @@ use reqwest::Client; #[serial_test::file_serial(model_load, path => "../target/model_load.lock")] #[tokio::test(flavor = "multi_thread")] async fn continuous_batch_long_and_short_prompts_complete_concurrently() -> Result<()> { - let cluster = start_in_process_cluster_with_qwen3(2).await?; + let cluster = start_in_process_cluster_with_qwen3(AgentConfig::single(2)).await?; let inference_client = InferenceHttpClient::new(Client::new(), cluster.addresses.inference_base_url()?); diff --git a/paddler_tests/tests/continuous_batch_plain_and_multimodal_run_concurrently.rs b/paddler_tests/tests/continuous_batch_plain_and_multimodal_run_concurrently.rs index 416b4aa4..234d7f35 100644 --- a/paddler_tests/tests/continuous_batch_plain_and_multimodal_run_concurrently.rs +++ b/paddler_tests/tests/continuous_batch_plain_and_multimodal_run_concurrently.rs @@ -1,6 +1,7 @@ #![cfg(feature = "tests_that_use_llms")] use anyhow::Result; +use paddler_tests::agent_config::AgentConfig; use paddler_tests::collect_generated_tokens::collect_generated_tokens; use paddler_tests::inference_http_client::InferenceHttpClient; use paddler_tests::load_test_image_data_uri::load_test_image_data_uri; @@ -19,7 +20,7 @@ use reqwest::Client; #[serial_test::file_serial(model_load, path => "../target/model_load.lock")] #[tokio::test(flavor = "multi_thread")] async fn continuous_batch_plain_and_multimodal_run_concurrently() -> Result<()> { - let cluster = start_in_process_cluster_with_qwen3_5(4, true).await?; + let cluster = start_in_process_cluster_with_qwen3_5(AgentConfig::single(4), true).await?; let inference_client = InferenceHttpClient::new(Client::new(), cluster.addresses.inference_base_url()?); diff --git a/paddler_tests/tests/continuous_batch_rejects_embedding_during_active_generation.rs b/paddler_tests/tests/continuous_batch_rejects_embedding_during_active_generation.rs index 47a2a6bc..099e2baa 100644 --- a/paddler_tests/tests/continuous_batch_rejects_embedding_during_active_generation.rs +++ b/paddler_tests/tests/continuous_batch_rejects_embedding_during_active_generation.rs @@ -2,6 +2,7 @@ use anyhow::Result; use futures_util::StreamExt as _; +use paddler_tests::agent_config::AgentConfig; use paddler_tests::collect_embedding_results::collect_embedding_results; use paddler_tests::collect_generated_tokens::collect_generated_tokens; use paddler_tests::inference_http_client::InferenceHttpClient; @@ -15,7 +16,7 @@ use reqwest::Client; #[serial_test::file_serial(model_load, path => "../target/model_load.lock")] #[tokio::test(flavor = "multi_thread")] async fn continuous_batch_rejects_embedding_during_active_generation() -> Result<()> { - let cluster = start_in_process_cluster_with_qwen3(2).await?; + let cluster = start_in_process_cluster_with_qwen3(AgentConfig::single(2)).await?; let inference_client = InferenceHttpClient::new(Client::new(), cluster.addresses.inference_base_url()?); diff --git a/paddler_tests/tests/continuous_batch_rejects_second_request_when_only_slot_busy.rs b/paddler_tests/tests/continuous_batch_rejects_second_request_when_only_slot_busy.rs index bb22c61b..2a64475a 100644 --- a/paddler_tests/tests/continuous_batch_rejects_second_request_when_only_slot_busy.rs +++ b/paddler_tests/tests/continuous_batch_rejects_second_request_when_only_slot_busy.rs @@ -3,6 +3,7 @@ use anyhow::Context as _; use anyhow::Result; use futures_util::StreamExt as _; +use paddler_tests::agent_config::AgentConfig; use paddler_tests::agents_status::assert_slots_processing::assert_slots_processing; use paddler_tests::collect_generated_tokens::collect_generated_tokens; use paddler_tests::current_test_device::current_test_device; @@ -29,8 +30,10 @@ async fn continuous_batch_rejects_second_request_when_only_slot_busy() -> Result } = qwen3_0_6b(); let mut cluster = start_in_process_cluster(InProcessClusterParams { - spawn_agent: true, - slots_per_agent: 1, + agent: Some(AgentConfig { + name: "test-agent".to_owned(), + slot_count: 1, + }), max_buffered_requests: 0, desired_state: BalancerDesiredState { chat_template_override: None, diff --git a/paddler_tests/tests/continuous_batch_releases_slot_when_client_disconnects.rs b/paddler_tests/tests/continuous_batch_releases_slot_when_client_disconnects.rs index 3f1b4a7f..f078eaab 100644 --- a/paddler_tests/tests/continuous_batch_releases_slot_when_client_disconnects.rs +++ b/paddler_tests/tests/continuous_batch_releases_slot_when_client_disconnects.rs @@ -3,6 +3,7 @@ use anyhow::Context as _; use anyhow::Result; use futures_util::StreamExt as _; +use paddler_tests::agent_config::AgentConfig; use paddler_tests::agents_status::assert_slots_processing::assert_slots_processing; use paddler_tests::inference_http_client::InferenceHttpClient; use paddler_tests::start_in_process_cluster_with_qwen3::start_in_process_cluster_with_qwen3; @@ -12,7 +13,7 @@ use reqwest::Client; #[serial_test::file_serial(model_load, path => "../target/model_load.lock")] #[tokio::test(flavor = "multi_thread")] async fn continuous_batch_releases_slot_when_client_disconnects() -> Result<()> { - let mut cluster = start_in_process_cluster_with_qwen3(1).await?; + let mut cluster = start_in_process_cluster_with_qwen3(AgentConfig::single(1)).await?; let agent_id = cluster .agent_ids diff --git a/paddler_tests/tests/continuous_batch_releases_slots_on_shutdown_with_active_request.rs b/paddler_tests/tests/continuous_batch_releases_slots_on_shutdown_with_active_request.rs index ba3873c7..cc3f9e2c 100644 --- a/paddler_tests/tests/continuous_batch_releases_slots_on_shutdown_with_active_request.rs +++ b/paddler_tests/tests/continuous_batch_releases_slots_on_shutdown_with_active_request.rs @@ -2,6 +2,7 @@ use anyhow::Result; use futures_util::StreamExt as _; +use paddler_tests::agent_config::AgentConfig; use paddler_tests::inference_http_client::InferenceHttpClient; use paddler_tests::start_in_process_cluster_with_qwen3::start_in_process_cluster_with_qwen3; use paddler_types::request_params::ContinueFromRawPromptParams; @@ -10,7 +11,7 @@ use reqwest::Client; #[serial_test::file_serial(model_load, path => "../target/model_load.lock")] #[tokio::test(flavor = "multi_thread")] async fn continuous_batch_releases_slots_on_shutdown_with_active_request() -> Result<()> { - let cluster = start_in_process_cluster_with_qwen3(1).await?; + let cluster = start_in_process_cluster_with_qwen3(AgentConfig::single(1)).await?; let inference_client = InferenceHttpClient::new(Client::new(), cluster.addresses.inference_base_url()?); diff --git a/paddler_tests/tests/continuous_batch_reuses_slot_after_request_completes.rs b/paddler_tests/tests/continuous_batch_reuses_slot_after_request_completes.rs index d3192fa1..0c07db46 100644 --- a/paddler_tests/tests/continuous_batch_reuses_slot_after_request_completes.rs +++ b/paddler_tests/tests/continuous_batch_reuses_slot_after_request_completes.rs @@ -1,6 +1,7 @@ #![cfg(feature = "tests_that_use_llms")] use anyhow::Result; +use paddler_tests::agent_config::AgentConfig; use paddler_tests::collect_generated_tokens::collect_generated_tokens; use paddler_tests::inference_http_client::InferenceHttpClient; use paddler_tests::start_in_process_cluster_with_qwen3::start_in_process_cluster_with_qwen3; @@ -12,7 +13,7 @@ use reqwest::Client; #[serial_test::file_serial(model_load, path => "../target/model_load.lock")] #[tokio::test(flavor = "multi_thread")] async fn continuous_batch_reuses_slot_after_request_completes() -> Result<()> { - let cluster = start_in_process_cluster_with_qwen3(1).await?; + let cluster = start_in_process_cluster_with_qwen3(AgentConfig::single(1)).await?; let inference_client = InferenceHttpClient::new(Client::new(), cluster.addresses.inference_base_url()?); diff --git a/paddler_tests/tests/continuous_batch_serves_four_concurrent_requests.rs b/paddler_tests/tests/continuous_batch_serves_four_concurrent_requests.rs index 0488cca4..7dd5cfff 100644 --- a/paddler_tests/tests/continuous_batch_serves_four_concurrent_requests.rs +++ b/paddler_tests/tests/continuous_batch_serves_four_concurrent_requests.rs @@ -1,6 +1,7 @@ #![cfg(feature = "tests_that_use_llms")] use anyhow::Result; +use paddler_tests::agent_config::AgentConfig; use paddler_tests::collect_generated_tokens::collect_generated_tokens; use paddler_tests::inference_http_client::InferenceHttpClient; use paddler_tests::start_in_process_cluster_with_qwen3::start_in_process_cluster_with_qwen3; @@ -12,7 +13,7 @@ use reqwest::Client; #[serial_test::file_serial(model_load, path => "../target/model_load.lock")] #[tokio::test(flavor = "multi_thread")] async fn continuous_batch_serves_four_concurrent_requests() -> Result<()> { - let cluster = start_in_process_cluster_with_qwen3(4).await?; + let cluster = start_in_process_cluster_with_qwen3(AgentConfig::single(4)).await?; let inference_client = InferenceHttpClient::new(Client::new(), cluster.addresses.inference_base_url()?); diff --git a/paddler_tests/tests/continuous_batch_smoke.rs b/paddler_tests/tests/continuous_batch_smoke.rs index 5b16c777..f91c3f9c 100644 --- a/paddler_tests/tests/continuous_batch_smoke.rs +++ b/paddler_tests/tests/continuous_batch_smoke.rs @@ -2,6 +2,7 @@ use anyhow::Context as _; use anyhow::Result; +use paddler_tests::agent_config::AgentConfig; use paddler_tests::collect_generated_tokens::collect_generated_tokens; use paddler_tests::current_test_device::current_test_device; use paddler_tests::in_process_cluster_params::InProcessClusterParams; @@ -39,8 +40,10 @@ async fn continuous_batch_smoke_generates_tokens() -> Result<()> { }; let cluster = start_in_process_cluster(InProcessClusterParams { - spawn_agent: true, - slots_per_agent: 1, + agent: Some(AgentConfig { + name: "test-agent".to_owned(), + slot_count: 1, + }), desired_state, wait_for_slots_ready: true, ..InProcessClusterParams::default() diff --git a/paddler_tests/tests/continuous_batch_stop_signal_terminates_generation_before_max_tokens.rs b/paddler_tests/tests/continuous_batch_stop_signal_terminates_generation_before_max_tokens.rs index 09a3a698..307cd479 100644 --- a/paddler_tests/tests/continuous_batch_stop_signal_terminates_generation_before_max_tokens.rs +++ b/paddler_tests/tests/continuous_batch_stop_signal_terminates_generation_before_max_tokens.rs @@ -3,6 +3,7 @@ use anyhow::Context as _; use anyhow::Result; use futures_util::StreamExt as _; +use paddler_tests::agent_config::AgentConfig; use paddler_tests::agents_status::assert_slots_processing::assert_slots_processing; use paddler_tests::inference_http_client::InferenceHttpClient; use paddler_tests::start_in_process_cluster_with_qwen3::start_in_process_cluster_with_qwen3; @@ -12,7 +13,7 @@ use reqwest::Client; #[serial_test::file_serial(model_load, path => "../target/model_load.lock")] #[tokio::test(flavor = "multi_thread")] async fn continuous_batch_stop_signal_terminates_generation_before_max_tokens() -> Result<()> { - let mut cluster = start_in_process_cluster_with_qwen3(1).await?; + let mut cluster = start_in_process_cluster_with_qwen3(AgentConfig::single(1)).await?; let agent_id = cluster .agent_ids diff --git a/paddler_tests/tests/continuous_batch_stops_at_max_tokens_boundary.rs b/paddler_tests/tests/continuous_batch_stops_at_max_tokens_boundary.rs index 5900f7d7..61423f02 100644 --- a/paddler_tests/tests/continuous_batch_stops_at_max_tokens_boundary.rs +++ b/paddler_tests/tests/continuous_batch_stops_at_max_tokens_boundary.rs @@ -1,6 +1,7 @@ #![cfg(feature = "tests_that_use_llms")] use anyhow::Result; +use paddler_tests::agent_config::AgentConfig; use paddler_tests::collect_generated_tokens::collect_generated_tokens; use paddler_tests::inference_http_client::InferenceHttpClient; use paddler_tests::start_in_process_cluster_with_qwen3::start_in_process_cluster_with_qwen3; @@ -12,7 +13,7 @@ use reqwest::Client; #[serial_test::file_serial(model_load, path => "../target/model_load.lock")] #[tokio::test(flavor = "multi_thread")] async fn continuous_batch_stops_at_max_tokens_boundary() -> Result<()> { - let cluster = start_in_process_cluster_with_qwen3(1).await?; + let cluster = start_in_process_cluster_with_qwen3(AgentConfig::single(1)).await?; let inference_client = InferenceHttpClient::new(Client::new(), cluster.addresses.inference_base_url()?); diff --git a/paddler_tests/tests/continuous_batch_stops_generation_when_stop_sender_dropped.rs b/paddler_tests/tests/continuous_batch_stops_generation_when_stop_sender_dropped.rs index 3054c029..445db487 100644 --- a/paddler_tests/tests/continuous_batch_stops_generation_when_stop_sender_dropped.rs +++ b/paddler_tests/tests/continuous_batch_stops_generation_when_stop_sender_dropped.rs @@ -2,6 +2,7 @@ use anyhow::Result; use futures_util::StreamExt as _; +use paddler_tests::agent_config::AgentConfig; use paddler_tests::collect_generated_tokens::collect_generated_tokens; use paddler_tests::inference_http_client::InferenceHttpClient; use paddler_tests::start_in_process_cluster_with_qwen3::start_in_process_cluster_with_qwen3; @@ -13,7 +14,7 @@ use reqwest::Client; #[serial_test::file_serial(model_load, path => "../target/model_load.lock")] #[tokio::test(flavor = "multi_thread")] async fn continuous_batch_stops_generation_when_stop_sender_dropped() -> Result<()> { - let cluster = start_in_process_cluster_with_qwen3(2).await?; + let cluster = start_in_process_cluster_with_qwen3(AgentConfig::single(2)).await?; let inference_client = InferenceHttpClient::new(Client::new(), cluster.addresses.inference_base_url()?); diff --git a/paddler_tests/tests/continuous_batch_two_concurrent_multimodal_requests_produce_tokens.rs b/paddler_tests/tests/continuous_batch_two_concurrent_multimodal_requests_produce_tokens.rs index 8c846cc1..7de37fd7 100644 --- a/paddler_tests/tests/continuous_batch_two_concurrent_multimodal_requests_produce_tokens.rs +++ b/paddler_tests/tests/continuous_batch_two_concurrent_multimodal_requests_produce_tokens.rs @@ -1,6 +1,7 @@ #![cfg(feature = "tests_that_use_llms")] use anyhow::Result; +use paddler_tests::agent_config::AgentConfig; use paddler_tests::collect_generated_tokens::collect_generated_tokens; use paddler_tests::inference_http_client::InferenceHttpClient; use paddler_tests::load_test_image_data_uri::load_test_image_data_uri; @@ -48,7 +49,7 @@ fn build_multimodal_conversation(image_data_uri: &str) -> ConversationHistory { #[serial_test::file_serial(model_load, path => "../target/model_load.lock")] #[tokio::test(flavor = "multi_thread")] async fn continuous_batch_two_concurrent_multimodal_requests_produce_tokens() -> Result<()> { - let cluster = start_in_process_cluster_with_qwen3_5(4, true).await?; + let cluster = start_in_process_cluster_with_qwen3_5(AgentConfig::single(4), true).await?; let inference_client = InferenceHttpClient::new(Client::new(), cluster.addresses.inference_base_url()?); diff --git a/paddler_tests/tests/deepseek_r1_distill_llama_8b_internal_endpoint_emits_reasoning_tokens.rs b/paddler_tests/tests/deepseek_r1_distill_llama_8b_internal_endpoint_emits_reasoning_tokens.rs index 8ed1baf7..96fe5bbe 100644 --- a/paddler_tests/tests/deepseek_r1_distill_llama_8b_internal_endpoint_emits_reasoning_tokens.rs +++ b/paddler_tests/tests/deepseek_r1_distill_llama_8b_internal_endpoint_emits_reasoning_tokens.rs @@ -1,6 +1,7 @@ #![cfg(feature = "tests_that_use_llms")] use anyhow::Result; +use paddler_tests::agent_config::AgentConfig; use paddler_tests::collect_generated_tokens::collect_generated_tokens; use paddler_tests::inference_http_client::InferenceHttpClient; use paddler_tests::start_in_process_cluster_with_deepseek_r1_distill_llama_8b::start_in_process_cluster_with_deepseek_r1_distill_llama_8b; @@ -14,7 +15,8 @@ use reqwest::Client; #[serial_test::file_serial(model_load, path => "../target/model_load.lock")] #[tokio::test(flavor = "multi_thread")] async fn deepseek_r1_distill_llama_8b_internal_endpoint_emits_reasoning_tokens() -> Result<()> { - let cluster = start_in_process_cluster_with_deepseek_r1_distill_llama_8b(1).await?; + let cluster = + start_in_process_cluster_with_deepseek_r1_distill_llama_8b(AgentConfig::single(1)).await?; let inference_client = InferenceHttpClient::new(Client::new(), cluster.addresses.inference_base_url()?); diff --git a/paddler_tests/tests/agent_returns_error_when_embeddings_disabled_in_parameters.rs b/paddler_tests/tests/endpoint_rejects_embedding_request_when_embeddings_disabled_in_parameters.rs similarity index 64% rename from paddler_tests/tests/agent_returns_error_when_embeddings_disabled_in_parameters.rs rename to paddler_tests/tests/endpoint_rejects_embedding_request_when_embeddings_disabled_in_parameters.rs index 25d26b69..f3245126 100644 --- a/paddler_tests/tests/agent_returns_error_when_embeddings_disabled_in_parameters.rs +++ b/paddler_tests/tests/endpoint_rejects_embedding_request_when_embeddings_disabled_in_parameters.rs @@ -1,9 +1,8 @@ #![cfg(feature = "tests_that_use_llms")] use anyhow::Result; -use paddler_tests::collect_embedding_results::collect_embedding_results; +use paddler_tests::agent_config::AgentConfig; use paddler_tests::in_process_cluster_params::InProcessClusterParams; -use paddler_tests::inference_http_client::InferenceHttpClient; use paddler_tests::model_card::ModelCard; use paddler_tests::model_card::qwen3_0_6b::qwen3_0_6b; use paddler_tests::start_in_process_cluster::start_in_process_cluster; @@ -14,15 +13,18 @@ use paddler_types::embedding_normalization_method::EmbeddingNormalizationMethod; use paddler_types::inference_parameters::InferenceParameters; use paddler_types::request_params::GenerateEmbeddingBatchParams; use reqwest::Client; +use reqwest::StatusCode; #[serial_test::file_serial(model_load, path => "../target/model_load.lock")] #[tokio::test(flavor = "multi_thread")] -async fn agent_returns_error_when_embeddings_disabled_in_parameters() -> Result<()> { +async fn endpoint_rejects_embedding_request_when_embeddings_disabled_in_parameters() -> Result<()> { let ModelCard { reference, .. } = qwen3_0_6b(); let cluster = start_in_process_cluster(InProcessClusterParams { - spawn_agent: true, - slots_per_agent: 1, + agent: Some(AgentConfig { + name: "test-agent".to_owned(), + slot_count: 1, + }), desired_state: BalancerDesiredState { chat_template_override: None, inference_parameters: InferenceParameters::default(), @@ -35,31 +37,26 @@ async fn agent_returns_error_when_embeddings_disabled_in_parameters() -> Result< }) .await?; - let inference_client = - InferenceHttpClient::new(Client::new(), cluster.addresses.inference_base_url()?); + let inference_base_url = cluster.addresses.inference_base_url()?; + let request_url = inference_base_url.join("api/v1/generate_embedding_batch")?; - let outcome = inference_client - .post_generate_embedding_batch(&GenerateEmbeddingBatchParams { + let response = Client::new() + .post(request_url) + .json(&GenerateEmbeddingBatchParams { input_batch: vec![EmbeddingInputDocument { content: "Hello world".to_owned(), id: "doc-1".to_owned(), }], normalization_method: EmbeddingNormalizationMethod::None, }) - .await; + .send() + .await?; - if let Ok(stream) = outcome { - let collected = collect_embedding_results(stream).await?; - - assert!( - collected.embeddings.is_empty(), - "no embeddings should be returned when embeddings are disabled" - ); - assert!( - !collected.errors.is_empty(), - "stream must report at least one embedding error when embeddings are disabled" - ); - } + assert_eq!( + response.status(), + StatusCode::NOT_IMPLEMENTED, + "endpoint must reject embedding requests with HTTP 501 when embeddings are disabled", + ); cluster.shutdown().await?; diff --git a/paddler_tests/tests/gemma4_internal_endpoint_emits_reasoning_tokens.rs b/paddler_tests/tests/gemma4_internal_endpoint_emits_reasoning_tokens.rs index 40b74edc..1f7df4a6 100644 --- a/paddler_tests/tests/gemma4_internal_endpoint_emits_reasoning_tokens.rs +++ b/paddler_tests/tests/gemma4_internal_endpoint_emits_reasoning_tokens.rs @@ -1,6 +1,7 @@ #![cfg(feature = "tests_that_use_llms")] use anyhow::Result; +use paddler_tests::agent_config::AgentConfig; use paddler_tests::collect_generated_tokens::collect_generated_tokens; use paddler_tests::inference_http_client::InferenceHttpClient; use paddler_tests::start_in_process_cluster_with_gemma_4::start_in_process_cluster_with_gemma_4; @@ -14,7 +15,7 @@ use reqwest::Client; #[serial_test::file_serial(model_load, path => "../target/model_load.lock")] #[tokio::test(flavor = "multi_thread")] async fn gemma4_internal_endpoint_emits_reasoning_tokens() -> Result<()> { - let cluster = start_in_process_cluster_with_gemma_4(1).await?; + let cluster = start_in_process_cluster_with_gemma_4(AgentConfig::single(1)).await?; let inference_client = InferenceHttpClient::new(Client::new(), cluster.addresses.inference_base_url()?); diff --git a/paddler_tests/tests/gemma4_internal_endpoint_emits_reasoning_tokens_for_image_request.rs b/paddler_tests/tests/gemma4_internal_endpoint_emits_reasoning_tokens_for_image_request.rs index 6b8f41ac..235c4679 100644 --- a/paddler_tests/tests/gemma4_internal_endpoint_emits_reasoning_tokens_for_image_request.rs +++ b/paddler_tests/tests/gemma4_internal_endpoint_emits_reasoning_tokens_for_image_request.rs @@ -1,6 +1,7 @@ #![cfg(feature = "tests_that_use_llms")] use anyhow::Result; +use paddler_tests::agent_config::AgentConfig; use paddler_tests::collect_generated_tokens::collect_generated_tokens; use paddler_tests::inference_http_client::InferenceHttpClient; use paddler_tests::load_test_image_data_uri::load_test_image_data_uri; @@ -17,7 +18,7 @@ use reqwest::Client; #[serial_test::file_serial(model_load, path => "../target/model_load.lock")] #[tokio::test(flavor = "multi_thread")] async fn gemma4_internal_endpoint_emits_reasoning_tokens_for_image_request() -> Result<()> { - let cluster = start_in_process_cluster_with_gemma_4_and_mmproj(1).await?; + let cluster = start_in_process_cluster_with_gemma_4_and_mmproj(AgentConfig::single(1)).await?; let inference_client = InferenceHttpClient::new(Client::new(), cluster.addresses.inference_base_url()?); diff --git a/paddler_tests/tests/gemma4_internal_endpoint_emits_tool_call_parsed_event.rs b/paddler_tests/tests/gemma4_internal_endpoint_emits_tool_call_parsed_event.rs index 071010d1..4b2dccdc 100644 --- a/paddler_tests/tests/gemma4_internal_endpoint_emits_tool_call_parsed_event.rs +++ b/paddler_tests/tests/gemma4_internal_endpoint_emits_tool_call_parsed_event.rs @@ -1,6 +1,7 @@ #![cfg(feature = "tests_that_use_llms")] use anyhow::Result; +use paddler_tests::agent_config::AgentConfig; use paddler_tests::collect_generated_tokens::collect_generated_tokens; use paddler_tests::inference_http_client::InferenceHttpClient; use paddler_tests::start_in_process_cluster_with_gemma_4::start_in_process_cluster_with_gemma_4; @@ -21,7 +22,7 @@ use serde_json::Value; #[serial_test::file_serial(model_load, path => "../target/model_load.lock")] #[tokio::test(flavor = "multi_thread")] async fn gemma4_internal_endpoint_emits_tool_call_parsed_event() -> Result<()> { - let cluster = start_in_process_cluster_with_gemma_4(1).await?; + let cluster = start_in_process_cluster_with_gemma_4(AgentConfig::single(1)).await?; let inference_client = InferenceHttpClient::new(Client::new(), cluster.addresses.inference_base_url()?); diff --git a/paddler_tests/tests/glm_4_7_flash_internal_endpoint_emits_reasoning_tokens.rs b/paddler_tests/tests/glm_4_7_flash_internal_endpoint_emits_reasoning_tokens.rs index dfb00268..896ade28 100644 --- a/paddler_tests/tests/glm_4_7_flash_internal_endpoint_emits_reasoning_tokens.rs +++ b/paddler_tests/tests/glm_4_7_flash_internal_endpoint_emits_reasoning_tokens.rs @@ -1,6 +1,7 @@ #![cfg(feature = "tests_that_use_llms")] use anyhow::Result; +use paddler_tests::agent_config::AgentConfig; use paddler_tests::collect_generated_tokens::collect_generated_tokens; use paddler_tests::inference_http_client::InferenceHttpClient; use paddler_tests::start_in_process_cluster_with_glm_4_7_flash::start_in_process_cluster_with_glm_4_7_flash; @@ -14,7 +15,7 @@ use reqwest::Client; #[serial_test::file_serial(model_load, path => "../target/model_load.lock")] #[tokio::test(flavor = "multi_thread")] async fn glm_4_7_flash_internal_endpoint_emits_reasoning_tokens() -> Result<()> { - let cluster = start_in_process_cluster_with_glm_4_7_flash(1).await?; + let cluster = start_in_process_cluster_with_glm_4_7_flash(AgentConfig::single(1)).await?; let inference_client = InferenceHttpClient::new(Client::new(), cluster.addresses.inference_base_url()?); diff --git a/paddler_tests/tests/glm_4_7_flash_internal_endpoint_emits_tool_call_parsed_event.rs b/paddler_tests/tests/glm_4_7_flash_internal_endpoint_emits_tool_call_parsed_event.rs index a025041d..eb5d9159 100644 --- a/paddler_tests/tests/glm_4_7_flash_internal_endpoint_emits_tool_call_parsed_event.rs +++ b/paddler_tests/tests/glm_4_7_flash_internal_endpoint_emits_tool_call_parsed_event.rs @@ -1,6 +1,7 @@ #![cfg(feature = "tests_that_use_llms")] use anyhow::Result; +use paddler_tests::agent_config::AgentConfig; use paddler_tests::collect_generated_tokens::collect_generated_tokens; use paddler_tests::inference_http_client::InferenceHttpClient; use paddler_tests::start_in_process_cluster_with_glm_4_7_flash::start_in_process_cluster_with_glm_4_7_flash; @@ -21,7 +22,7 @@ use serde_json::Value; #[serial_test::file_serial(model_load, path => "../target/model_load.lock")] #[tokio::test(flavor = "multi_thread")] async fn glm_4_7_flash_internal_endpoint_emits_tool_call_parsed_event() -> Result<()> { - let cluster = start_in_process_cluster_with_glm_4_7_flash(1).await?; + let cluster = start_in_process_cluster_with_glm_4_7_flash(AgentConfig::single(1)).await?; let inference_client = InferenceHttpClient::new(Client::new(), cluster.addresses.inference_base_url()?); diff --git a/paddler_tests/tests/harness_agents_watcher.rs b/paddler_tests/tests/harness_agents_watcher.rs index e1c805a0..fac8d7d9 100644 --- a/paddler_tests/tests/harness_agents_watcher.rs +++ b/paddler_tests/tests/harness_agents_watcher.rs @@ -101,7 +101,7 @@ async fn wait_for_slots_ready_includes_agent_id_in_error() { let fixture = stream::iter(vec![Ok(snapshot)]); let mut watcher = AgentsStreamWatcher::from_stream(Box::pin(fixture)); - let outcome = watcher.wait_for_slots_ready(1, 1).await; + let outcome = watcher.wait_for_slots_ready(&[1]).await; assert!( outcome.is_err(), diff --git a/paddler_tests/tests/harness_in_process_cluster_shutdown.rs b/paddler_tests/tests/harness_in_process_cluster_shutdown.rs index 8a023ce1..7b619834 100644 --- a/paddler_tests/tests/harness_in_process_cluster_shutdown.rs +++ b/paddler_tests/tests/harness_in_process_cluster_shutdown.rs @@ -5,7 +5,7 @@ use paddler_tests::start_in_process_cluster::start_in_process_cluster; #[tokio::test(flavor = "multi_thread")] async fn empty_cluster_starts_and_shuts_down_without_timeout() -> Result<()> { let cluster = start_in_process_cluster(InProcessClusterParams { - spawn_agent: false, + agent: None, wait_for_slots_ready: false, ..InProcessClusterParams::default() }) @@ -19,7 +19,6 @@ async fn empty_cluster_starts_and_shuts_down_without_timeout() -> Result<()> { #[tokio::test(flavor = "multi_thread")] async fn single_agent_registers_and_shuts_down_without_timeout() -> Result<()> { let cluster = start_in_process_cluster(InProcessClusterParams { - spawn_agent: true, wait_for_slots_ready: false, ..InProcessClusterParams::default() }) diff --git a/paddler_tests/tests/harness_subprocess_cluster_shutdown.rs b/paddler_tests/tests/harness_subprocess_cluster_shutdown.rs index 6c363ccb..c8a4e6a2 100644 --- a/paddler_tests/tests/harness_subprocess_cluster_shutdown.rs +++ b/paddler_tests/tests/harness_subprocess_cluster_shutdown.rs @@ -1,13 +1,14 @@ #![cfg(feature = "tests_that_use_compiled_paddler")] use anyhow::Result; +use paddler_tests::agent_config::AgentConfig; use paddler_tests::start_subprocess_cluster::start_subprocess_cluster; use paddler_tests::subprocess_cluster_params::SubprocessClusterParams; #[tokio::test(flavor = "multi_thread")] async fn empty_subprocess_cluster_starts_and_exits_after_sigterm() -> Result<()> { let cluster = start_subprocess_cluster(SubprocessClusterParams { - agent_count: 0, + agents: Vec::new(), wait_for_slots_ready: false, ..SubprocessClusterParams::default() }) @@ -21,7 +22,7 @@ async fn empty_subprocess_cluster_starts_and_exits_after_sigterm() -> Result<()> #[tokio::test(flavor = "multi_thread")] async fn single_subprocess_agent_registers_and_exits_after_sigterm() -> Result<()> { let cluster = start_subprocess_cluster(SubprocessClusterParams { - agent_count: 1, + agents: AgentConfig::uniform(1, 4), wait_for_slots_ready: false, ..SubprocessClusterParams::default() }) diff --git a/paddler_tests/tests/management_agents_stream_yields_initial_snapshot.rs b/paddler_tests/tests/management_agents_stream_yields_initial_snapshot.rs index 3e64ff08..3593057f 100644 --- a/paddler_tests/tests/management_agents_stream_yields_initial_snapshot.rs +++ b/paddler_tests/tests/management_agents_stream_yields_initial_snapshot.rs @@ -6,12 +6,13 @@ use anyhow::Context as _; use anyhow::Result; use futures_util::StreamExt as _; +use paddler_tests::agent_config::AgentConfig; use paddler_tests::start_subprocess_cluster_with_qwen3::start_subprocess_cluster_with_qwen3; #[serial_test::file_serial(model_load, path => "../target/model_load.lock")] #[tokio::test(flavor = "multi_thread")] async fn management_agents_stream_yields_initial_snapshot() -> Result<()> { - let cluster = start_subprocess_cluster_with_qwen3(2, 1).await?; + let cluster = start_subprocess_cluster_with_qwen3(AgentConfig::uniform(1, 2)).await?; let mut stream = cluster .paddler_client diff --git a/paddler_tests/tests/management_buffered_requests_stream_yields_initial_snapshot.rs b/paddler_tests/tests/management_buffered_requests_stream_yields_initial_snapshot.rs index 35a02d31..43028a6c 100644 --- a/paddler_tests/tests/management_buffered_requests_stream_yields_initial_snapshot.rs +++ b/paddler_tests/tests/management_buffered_requests_stream_yields_initial_snapshot.rs @@ -9,7 +9,7 @@ use paddler_tests::subprocess_cluster_params::SubprocessClusterParams; #[tokio::test(flavor = "multi_thread")] async fn management_buffered_requests_stream_yields_initial_snapshot() -> Result<()> { let cluster = start_subprocess_cluster(SubprocessClusterParams { - agent_count: 0, + agents: Vec::new(), wait_for_slots_ready: false, ..SubprocessClusterParams::default() }) diff --git a/paddler_tests/tests/management_health_endpoint_returns_ok.rs b/paddler_tests/tests/management_health_endpoint_returns_ok.rs index 2fc1d631..367258f8 100644 --- a/paddler_tests/tests/management_health_endpoint_returns_ok.rs +++ b/paddler_tests/tests/management_health_endpoint_returns_ok.rs @@ -8,7 +8,7 @@ use paddler_tests::subprocess_cluster_params::SubprocessClusterParams; #[tokio::test(flavor = "multi_thread")] async fn management_health_endpoint_returns_ok() -> Result<()> { let cluster = start_subprocess_cluster(SubprocessClusterParams { - agent_count: 0, + agents: Vec::new(), wait_for_slots_ready: false, ..SubprocessClusterParams::default() }) diff --git a/paddler_tests/tests/management_metrics_endpoint_exposes_prometheus_gauges.rs b/paddler_tests/tests/management_metrics_endpoint_exposes_prometheus_gauges.rs index ca31e92a..98ecc865 100644 --- a/paddler_tests/tests/management_metrics_endpoint_exposes_prometheus_gauges.rs +++ b/paddler_tests/tests/management_metrics_endpoint_exposes_prometheus_gauges.rs @@ -8,7 +8,7 @@ use paddler_tests::subprocess_cluster_params::SubprocessClusterParams; #[tokio::test(flavor = "multi_thread")] async fn management_metrics_endpoint_exposes_prometheus_gauges() -> Result<()> { let cluster = start_subprocess_cluster(SubprocessClusterParams { - agent_count: 0, + agents: Vec::new(), wait_for_slots_ready: false, ..SubprocessClusterParams::default() }) diff --git a/paddler_tests/tests/management_reports_zero_download_progress_after_load_complete.rs b/paddler_tests/tests/management_reports_zero_download_progress_after_load_complete.rs index 6248afc1..22de1ecd 100644 --- a/paddler_tests/tests/management_reports_zero_download_progress_after_load_complete.rs +++ b/paddler_tests/tests/management_reports_zero_download_progress_after_load_complete.rs @@ -5,12 +5,13 @@ use anyhow::Context as _; use anyhow::Result; +use paddler_tests::agent_config::AgentConfig; use paddler_tests::start_subprocess_cluster_with_qwen3::start_subprocess_cluster_with_qwen3; #[serial_test::file_serial(model_load, path => "../target/model_load.lock")] #[tokio::test(flavor = "multi_thread")] async fn management_reports_zero_download_progress_after_load_complete() -> Result<()> { - let cluster = start_subprocess_cluster_with_qwen3(2, 1).await?; + let cluster = start_subprocess_cluster_with_qwen3(AgentConfig::uniform(1, 2)).await?; let snapshot = cluster .paddler_client diff --git a/paddler_tests/tests/management_returns_model_metadata_for_loaded_agent.rs b/paddler_tests/tests/management_returns_model_metadata_for_loaded_agent.rs index 5d17a401..bdd49fc7 100644 --- a/paddler_tests/tests/management_returns_model_metadata_for_loaded_agent.rs +++ b/paddler_tests/tests/management_returns_model_metadata_for_loaded_agent.rs @@ -5,12 +5,13 @@ use anyhow::Context as _; use anyhow::Result; +use paddler_tests::agent_config::AgentConfig; use paddler_tests::start_subprocess_cluster_with_qwen3::start_subprocess_cluster_with_qwen3; #[serial_test::file_serial(model_load, path => "../target/model_load.lock")] #[tokio::test(flavor = "multi_thread")] async fn management_returns_model_metadata_for_loaded_agent() -> Result<()> { - let cluster = start_subprocess_cluster_with_qwen3(2, 1).await?; + let cluster = start_subprocess_cluster_with_qwen3(AgentConfig::uniform(1, 2)).await?; let agent_id = cluster .agent_ids diff --git a/paddler_tests/tests/management_two_agents_stream_subscribers_receive_slot_usage_updates.rs b/paddler_tests/tests/management_two_agents_stream_subscribers_receive_slot_usage_updates.rs index 6b1ab8ab..b5a7ef4a 100644 --- a/paddler_tests/tests/management_two_agents_stream_subscribers_receive_slot_usage_updates.rs +++ b/paddler_tests/tests/management_two_agents_stream_subscribers_receive_slot_usage_updates.rs @@ -2,6 +2,7 @@ use anyhow::Context as _; use anyhow::Result; +use paddler_tests::agent_config::AgentConfig; use paddler_tests::agents_status::assert_slots_processing::assert_slots_processing; use paddler_tests::collect_generated_tokens::collect_generated_tokens; use paddler_tests::current_test_device::current_test_device; @@ -36,8 +37,10 @@ async fn management_two_agents_stream_subscribers_receive_slot_usage_updates() - }; let mut cluster = start_in_process_cluster(InProcessClusterParams { - spawn_agent: true, - slots_per_agent: 1, + agent: Some(AgentConfig { + name: "test-agent".to_owned(), + slot_count: 1, + }), desired_state, wait_for_slots_ready: true, ..InProcessClusterParams::default() diff --git a/paddler_tests/tests/mistral3_internal_endpoint_emits_reasoning_tokens.rs b/paddler_tests/tests/mistral3_internal_endpoint_emits_reasoning_tokens.rs index cb0de139..b4e3ad10 100644 --- a/paddler_tests/tests/mistral3_internal_endpoint_emits_reasoning_tokens.rs +++ b/paddler_tests/tests/mistral3_internal_endpoint_emits_reasoning_tokens.rs @@ -1,6 +1,7 @@ #![cfg(feature = "tests_that_use_llms")] use anyhow::Result; +use paddler_tests::agent_config::AgentConfig; use paddler_tests::collect_generated_tokens::collect_generated_tokens; use paddler_tests::inference_http_client::InferenceHttpClient; use paddler_tests::start_in_process_cluster_with_ministral_3::start_in_process_cluster_with_ministral_3; @@ -14,7 +15,7 @@ use reqwest::Client; #[serial_test::file_serial(model_load, path => "../target/model_load.lock")] #[tokio::test(flavor = "multi_thread")] async fn mistral3_internal_endpoint_emits_reasoning_tokens() -> Result<()> { - let cluster = start_in_process_cluster_with_ministral_3(1).await?; + let cluster = start_in_process_cluster_with_ministral_3(AgentConfig::single(1)).await?; let inference_client = InferenceHttpClient::new(Client::new(), cluster.addresses.inference_base_url()?); diff --git a/paddler_tests/tests/mistral3_internal_endpoint_emits_reasoning_tokens_for_image_request.rs b/paddler_tests/tests/mistral3_internal_endpoint_emits_reasoning_tokens_for_image_request.rs index defeb7a2..7a5764fe 100644 --- a/paddler_tests/tests/mistral3_internal_endpoint_emits_reasoning_tokens_for_image_request.rs +++ b/paddler_tests/tests/mistral3_internal_endpoint_emits_reasoning_tokens_for_image_request.rs @@ -1,6 +1,7 @@ #![cfg(feature = "tests_that_use_llms")] use anyhow::Result; +use paddler_tests::agent_config::AgentConfig; use paddler_tests::collect_generated_tokens::collect_generated_tokens; use paddler_tests::inference_http_client::InferenceHttpClient; use paddler_tests::load_test_image_data_uri::load_test_image_data_uri; @@ -17,7 +18,8 @@ use reqwest::Client; #[serial_test::file_serial(model_load, path => "../target/model_load.lock")] #[tokio::test(flavor = "multi_thread")] async fn mistral3_internal_endpoint_emits_reasoning_tokens_for_image_request() -> Result<()> { - let cluster = start_in_process_cluster_with_ministral_3_and_mmproj(1).await?; + let cluster = + start_in_process_cluster_with_ministral_3_and_mmproj(AgentConfig::single(1)).await?; let inference_client = InferenceHttpClient::new(Client::new(), cluster.addresses.inference_base_url()?); diff --git a/paddler_tests/tests/mistral3_internal_endpoint_emits_tool_call_parsed_event.rs b/paddler_tests/tests/mistral3_internal_endpoint_emits_tool_call_parsed_event.rs index 8fe06b21..bb8912b6 100644 --- a/paddler_tests/tests/mistral3_internal_endpoint_emits_tool_call_parsed_event.rs +++ b/paddler_tests/tests/mistral3_internal_endpoint_emits_tool_call_parsed_event.rs @@ -1,6 +1,7 @@ #![cfg(feature = "tests_that_use_llms")] use anyhow::Result; +use paddler_tests::agent_config::AgentConfig; use paddler_tests::collect_generated_tokens::collect_generated_tokens; use paddler_tests::inference_http_client::InferenceHttpClient; use paddler_tests::start_in_process_cluster_with_ministral_3::start_in_process_cluster_with_ministral_3; @@ -21,7 +22,7 @@ use serde_json::Value; #[serial_test::file_serial(model_load, path => "../target/model_load.lock")] #[tokio::test(flavor = "multi_thread")] async fn mistral3_internal_endpoint_emits_tool_call_parsed_event() -> Result<()> { - let cluster = start_in_process_cluster_with_ministral_3(1).await?; + let cluster = start_in_process_cluster_with_ministral_3(AgentConfig::single(1)).await?; let inference_client = InferenceHttpClient::new(Client::new(), cluster.addresses.inference_base_url()?); diff --git a/paddler_tests/tests/qwen25vl_generates_tokens_from_image_input.rs b/paddler_tests/tests/qwen25vl_generates_tokens_from_image_input.rs index 111c2e02..0d2919f7 100644 --- a/paddler_tests/tests/qwen25vl_generates_tokens_from_image_input.rs +++ b/paddler_tests/tests/qwen25vl_generates_tokens_from_image_input.rs @@ -1,6 +1,7 @@ #![cfg(feature = "tests_that_use_llms")] use anyhow::Result; +use paddler_tests::agent_config::AgentConfig; use paddler_tests::collect_generated_tokens::collect_generated_tokens; use paddler_tests::inference_http_client::InferenceHttpClient; use paddler_tests::load_test_image_data_uri::load_test_image_data_uri; @@ -18,7 +19,7 @@ use reqwest::Client; #[serial_test::file_serial(model_load, path => "../target/model_load.lock")] #[tokio::test(flavor = "multi_thread")] async fn qwen25vl_generates_tokens_from_image_input() -> Result<()> { - let cluster = start_in_process_cluster_with_qwen2_5_vl(1).await?; + let cluster = start_in_process_cluster_with_qwen2_5_vl(AgentConfig::single(1)).await?; let inference_client = InferenceHttpClient::new(Client::new(), cluster.addresses.inference_base_url()?); diff --git a/paddler_tests/tests/qwen35_generates_tokens_for_long_system_and_user_prompt.rs b/paddler_tests/tests/qwen35_generates_tokens_for_long_system_and_user_prompt.rs index dd2cfe36..aee344cf 100644 --- a/paddler_tests/tests/qwen35_generates_tokens_for_long_system_and_user_prompt.rs +++ b/paddler_tests/tests/qwen35_generates_tokens_for_long_system_and_user_prompt.rs @@ -1,6 +1,7 @@ #![cfg(feature = "tests_that_use_llms")] use anyhow::Result; +use paddler_tests::agent_config::AgentConfig; use paddler_tests::collect_generated_tokens::collect_generated_tokens; use paddler_tests::inference_http_client::InferenceHttpClient; use paddler_tests::start_in_process_cluster_with_qwen3_5::start_in_process_cluster_with_qwen3_5; @@ -52,7 +53,7 @@ fn build_long_link_list() -> String { #[serial_test::file_serial(model_load, path => "../target/model_load.lock")] #[tokio::test(flavor = "multi_thread")] async fn qwen35_generates_tokens_for_long_system_and_user_prompt() -> Result<()> { - let cluster = start_in_process_cluster_with_qwen3_5(1, false).await?; + let cluster = start_in_process_cluster_with_qwen3_5(AgentConfig::single(1), false).await?; let inference_client = InferenceHttpClient::new(Client::new(), cluster.addresses.inference_base_url()?); diff --git a/paddler_tests/tests/qwen35_generation_stops_at_eog_before_max_tokens.rs b/paddler_tests/tests/qwen35_generation_stops_at_eog_before_max_tokens.rs index cb7b44d7..d9b53524 100644 --- a/paddler_tests/tests/qwen35_generation_stops_at_eog_before_max_tokens.rs +++ b/paddler_tests/tests/qwen35_generation_stops_at_eog_before_max_tokens.rs @@ -1,6 +1,7 @@ #![cfg(feature = "tests_that_use_llms")] use anyhow::Result; +use paddler_tests::agent_config::AgentConfig; use paddler_tests::collect_generated_tokens::collect_generated_tokens; use paddler_tests::inference_http_client::InferenceHttpClient; use paddler_tests::start_in_process_cluster_with_qwen3_5::start_in_process_cluster_with_qwen3_5; @@ -15,7 +16,7 @@ use reqwest::Client; #[serial_test::file_serial(model_load, path => "../target/model_load.lock")] #[tokio::test(flavor = "multi_thread")] async fn qwen35_generation_stops_at_eog_before_max_tokens() -> Result<()> { - let cluster = start_in_process_cluster_with_qwen3_5(1, false).await?; + let cluster = start_in_process_cluster_with_qwen3_5(AgentConfig::single(1), false).await?; let inference_client = InferenceHttpClient::new(Client::new(), cluster.addresses.inference_base_url()?); diff --git a/paddler_tests/tests/qwen35_internal_endpoint_emits_reasoning_tokens_for_image_request.rs b/paddler_tests/tests/qwen35_internal_endpoint_emits_reasoning_tokens_for_image_request.rs index 0014feea..2dbfe3e3 100644 --- a/paddler_tests/tests/qwen35_internal_endpoint_emits_reasoning_tokens_for_image_request.rs +++ b/paddler_tests/tests/qwen35_internal_endpoint_emits_reasoning_tokens_for_image_request.rs @@ -1,6 +1,7 @@ #![cfg(feature = "tests_that_use_llms")] use anyhow::Result; +use paddler_tests::agent_config::AgentConfig; use paddler_tests::collect_generated_tokens::collect_generated_tokens; use paddler_tests::inference_http_client::InferenceHttpClient; use paddler_tests::load_test_image_data_uri::load_test_image_data_uri; @@ -17,7 +18,7 @@ use reqwest::Client; #[serial_test::file_serial(model_load, path => "../target/model_load.lock")] #[tokio::test(flavor = "multi_thread")] async fn qwen35_internal_endpoint_emits_reasoning_tokens_for_image_request() -> Result<()> { - let cluster = start_in_process_cluster_with_qwen3_5(1, true).await?; + let cluster = start_in_process_cluster_with_qwen3_5(AgentConfig::single(1), true).await?; let inference_client = InferenceHttpClient::new(Client::new(), cluster.addresses.inference_base_url()?); diff --git a/paddler_tests/tests/qwen35_internal_endpoint_emits_tool_call_parsed_event.rs b/paddler_tests/tests/qwen35_internal_endpoint_emits_tool_call_parsed_event.rs index 7da3a578..aa06133c 100644 --- a/paddler_tests/tests/qwen35_internal_endpoint_emits_tool_call_parsed_event.rs +++ b/paddler_tests/tests/qwen35_internal_endpoint_emits_tool_call_parsed_event.rs @@ -1,6 +1,7 @@ #![cfg(feature = "tests_that_use_llms")] use anyhow::Result; +use paddler_tests::agent_config::AgentConfig; use paddler_tests::collect_generated_tokens::collect_generated_tokens; use paddler_tests::inference_http_client::InferenceHttpClient; use paddler_tests::start_in_process_cluster_with_qwen3_5::start_in_process_cluster_with_qwen3_5; @@ -21,7 +22,7 @@ use serde_json::Value; #[serial_test::file_serial(model_load, path => "../target/model_load.lock")] #[tokio::test(flavor = "multi_thread")] async fn qwen35_internal_endpoint_emits_tool_call_parsed_event() -> Result<()> { - let cluster = start_in_process_cluster_with_qwen3_5(1, false).await?; + let cluster = start_in_process_cluster_with_qwen3_5(AgentConfig::single(1), false).await?; let inference_client = InferenceHttpClient::new(Client::new(), cluster.addresses.inference_base_url()?); diff --git a/paddler_tests/tests/qwen35_internal_endpoint_with_thinking_disabled_emits_only_content_tokens.rs b/paddler_tests/tests/qwen35_internal_endpoint_with_thinking_disabled_emits_only_content_tokens.rs index 2024c1f7..e28c8746 100644 --- a/paddler_tests/tests/qwen35_internal_endpoint_with_thinking_disabled_emits_only_content_tokens.rs +++ b/paddler_tests/tests/qwen35_internal_endpoint_with_thinking_disabled_emits_only_content_tokens.rs @@ -1,6 +1,7 @@ #![cfg(feature = "tests_that_use_llms")] use anyhow::Result; +use paddler_tests::agent_config::AgentConfig; use paddler_tests::collect_generated_tokens::collect_generated_tokens; use paddler_tests::inference_http_client::InferenceHttpClient; use paddler_tests::start_in_process_cluster_with_qwen3_5::start_in_process_cluster_with_qwen3_5; @@ -14,7 +15,7 @@ use reqwest::Client; #[serial_test::file_serial(model_load, path => "../target/model_load.lock")] #[tokio::test(flavor = "multi_thread")] async fn qwen35_internal_endpoint_with_thinking_disabled_emits_only_content_tokens() -> Result<()> { - let cluster = start_in_process_cluster_with_qwen3_5(1, false).await?; + let cluster = start_in_process_cluster_with_qwen3_5(AgentConfig::single(1), false).await?; let inference_client = InferenceHttpClient::new(Client::new(), cluster.addresses.inference_base_url()?); diff --git a/paddler_tests/tests/qwen35_internal_endpoint_with_thinking_enabled_emits_reasoning_tokens.rs b/paddler_tests/tests/qwen35_internal_endpoint_with_thinking_enabled_emits_reasoning_tokens.rs index 0627e373..4089d30f 100644 --- a/paddler_tests/tests/qwen35_internal_endpoint_with_thinking_enabled_emits_reasoning_tokens.rs +++ b/paddler_tests/tests/qwen35_internal_endpoint_with_thinking_enabled_emits_reasoning_tokens.rs @@ -1,6 +1,7 @@ #![cfg(feature = "tests_that_use_llms")] use anyhow::Result; +use paddler_tests::agent_config::AgentConfig; use paddler_tests::collect_generated_tokens::collect_generated_tokens; use paddler_tests::inference_http_client::InferenceHttpClient; use paddler_tests::start_in_process_cluster_with_qwen3_5::start_in_process_cluster_with_qwen3_5; @@ -14,7 +15,7 @@ use reqwest::Client; #[serial_test::file_serial(model_load, path => "../target/model_load.lock")] #[tokio::test(flavor = "multi_thread")] async fn qwen35_internal_endpoint_with_thinking_enabled_emits_reasoning_tokens() -> Result<()> { - let cluster = start_in_process_cluster_with_qwen3_5(1, false).await?; + let cluster = start_in_process_cluster_with_qwen3_5(AgentConfig::single(1), false).await?; let inference_client = InferenceHttpClient::new(Client::new(), cluster.addresses.inference_base_url()?); diff --git a/paddler_tests/tests/qwen35_thinking_mode_stops_cleanly_before_max_tokens.rs b/paddler_tests/tests/qwen35_thinking_mode_stops_cleanly_before_max_tokens.rs index 67e5ab43..fd9e2a62 100644 --- a/paddler_tests/tests/qwen35_thinking_mode_stops_cleanly_before_max_tokens.rs +++ b/paddler_tests/tests/qwen35_thinking_mode_stops_cleanly_before_max_tokens.rs @@ -1,6 +1,7 @@ #![cfg(feature = "tests_that_use_llms")] use anyhow::Result; +use paddler_tests::agent_config::AgentConfig; use paddler_tests::collect_generated_tokens::collect_generated_tokens; use paddler_tests::inference_http_client::InferenceHttpClient; use paddler_tests::start_in_process_cluster_with_qwen3_5::start_in_process_cluster_with_qwen3_5; @@ -15,7 +16,7 @@ use reqwest::Client; #[serial_test::file_serial(model_load, path => "../target/model_load.lock")] #[tokio::test(flavor = "multi_thread")] async fn qwen35_thinking_mode_stops_cleanly_before_max_tokens() -> Result<()> { - let cluster = start_in_process_cluster_with_qwen3_5(1, false).await?; + let cluster = start_in_process_cluster_with_qwen3_5(AgentConfig::single(1), false).await?; let inference_client = InferenceHttpClient::new(Client::new(), cluster.addresses.inference_base_url()?); diff --git a/paddler_tests/tests/qwen35_thinking_multi_turn_conversation_stops_cleanly.rs b/paddler_tests/tests/qwen35_thinking_multi_turn_conversation_stops_cleanly.rs index 24c261ba..6fb55f20 100644 --- a/paddler_tests/tests/qwen35_thinking_multi_turn_conversation_stops_cleanly.rs +++ b/paddler_tests/tests/qwen35_thinking_multi_turn_conversation_stops_cleanly.rs @@ -1,6 +1,7 @@ #![cfg(feature = "tests_that_use_llms")] use anyhow::Result; +use paddler_tests::agent_config::AgentConfig; use paddler_tests::collect_generated_tokens::collect_generated_tokens; use paddler_tests::inference_http_client::InferenceHttpClient; use paddler_tests::start_in_process_cluster_with_qwen3_5::start_in_process_cluster_with_qwen3_5; @@ -15,7 +16,7 @@ use reqwest::Client; #[serial_test::file_serial(model_load, path => "../target/model_load.lock")] #[tokio::test(flavor = "multi_thread")] async fn qwen35_thinking_multi_turn_conversation_stops_cleanly() -> Result<()> { - let cluster = start_in_process_cluster_with_qwen3_5(1, false).await?; + let cluster = start_in_process_cluster_with_qwen3_5(AgentConfig::single(1), false).await?; let inference_client = InferenceHttpClient::new(Client::new(), cluster.addresses.inference_base_url()?); diff --git a/paddler_tests/tests/qwen35_with_mmproj_generates_tokens_from_image.rs b/paddler_tests/tests/qwen35_with_mmproj_generates_tokens_from_image.rs index a224b4ea..54292ff7 100644 --- a/paddler_tests/tests/qwen35_with_mmproj_generates_tokens_from_image.rs +++ b/paddler_tests/tests/qwen35_with_mmproj_generates_tokens_from_image.rs @@ -1,6 +1,7 @@ #![cfg(feature = "tests_that_use_llms")] use anyhow::Result; +use paddler_tests::agent_config::AgentConfig; use paddler_tests::collect_generated_tokens::collect_generated_tokens; use paddler_tests::inference_http_client::InferenceHttpClient; use paddler_tests::load_test_image_data_uri::load_test_image_data_uri; @@ -18,7 +19,7 @@ use reqwest::Client; #[serial_test::file_serial(model_load, path => "../target/model_load.lock")] #[tokio::test(flavor = "multi_thread")] async fn qwen35_with_mmproj_generates_tokens_from_image() -> Result<()> { - let cluster = start_in_process_cluster_with_qwen3_5(1, true).await?; + let cluster = start_in_process_cluster_with_qwen3_5(AgentConfig::single(1), true).await?; let inference_client = InferenceHttpClient::new(Client::new(), cluster.addresses.inference_base_url()?); diff --git a/paddler_tests/tests/qwen35_with_system_message_completes_with_thinking.rs b/paddler_tests/tests/qwen35_with_system_message_completes_with_thinking.rs index a592b85e..a92205eb 100644 --- a/paddler_tests/tests/qwen35_with_system_message_completes_with_thinking.rs +++ b/paddler_tests/tests/qwen35_with_system_message_completes_with_thinking.rs @@ -1,6 +1,7 @@ #![cfg(feature = "tests_that_use_llms")] use anyhow::Result; +use paddler_tests::agent_config::AgentConfig; use paddler_tests::collect_generated_tokens::collect_generated_tokens; use paddler_tests::inference_http_client::InferenceHttpClient; use paddler_tests::start_in_process_cluster_with_qwen3_5::start_in_process_cluster_with_qwen3_5; @@ -15,7 +16,7 @@ use reqwest::Client; #[serial_test::file_serial(model_load, path => "../target/model_load.lock")] #[tokio::test(flavor = "multi_thread")] async fn qwen35_with_system_message_completes_with_thinking() -> Result<()> { - let cluster = start_in_process_cluster_with_qwen3_5(1, false).await?; + let cluster = start_in_process_cluster_with_qwen3_5(AgentConfig::single(1), false).await?; let inference_client = InferenceHttpClient::new(Client::new(), cluster.addresses.inference_base_url()?); diff --git a/paddler_tests/tests/qwen35_with_system_message_completes_without_thinking.rs b/paddler_tests/tests/qwen35_with_system_message_completes_without_thinking.rs index d088edd5..6c2d0560 100644 --- a/paddler_tests/tests/qwen35_with_system_message_completes_without_thinking.rs +++ b/paddler_tests/tests/qwen35_with_system_message_completes_without_thinking.rs @@ -1,6 +1,7 @@ #![cfg(feature = "tests_that_use_llms")] use anyhow::Result; +use paddler_tests::agent_config::AgentConfig; use paddler_tests::collect_generated_tokens::collect_generated_tokens; use paddler_tests::inference_http_client::InferenceHttpClient; use paddler_tests::start_in_process_cluster_with_qwen3_5::start_in_process_cluster_with_qwen3_5; @@ -15,7 +16,7 @@ use reqwest::Client; #[serial_test::file_serial(model_load, path => "../target/model_load.lock")] #[tokio::test(flavor = "multi_thread")] async fn qwen35_with_system_message_completes_without_thinking() -> Result<()> { - let cluster = start_in_process_cluster_with_qwen3_5(1, false).await?; + let cluster = start_in_process_cluster_with_qwen3_5(AgentConfig::single(1), false).await?; let inference_client = InferenceHttpClient::new(Client::new(), cluster.addresses.inference_base_url()?); diff --git a/paddler_tests/tests/qwen35_without_mmproj_rejects_image_with_multimodal_not_supported.rs b/paddler_tests/tests/qwen35_without_mmproj_rejects_image_with_multimodal_not_supported.rs index 5c862278..7f2b6c28 100644 --- a/paddler_tests/tests/qwen35_without_mmproj_rejects_image_with_multimodal_not_supported.rs +++ b/paddler_tests/tests/qwen35_without_mmproj_rejects_image_with_multimodal_not_supported.rs @@ -1,6 +1,7 @@ #![cfg(feature = "tests_that_use_llms")] use anyhow::Result; +use paddler_tests::agent_config::AgentConfig; use paddler_tests::collect_generated_tokens::collect_generated_tokens; use paddler_tests::inference_http_client::InferenceHttpClient; use paddler_tests::load_test_image_data_uri::load_test_image_data_uri; @@ -17,7 +18,7 @@ use reqwest::Client; #[serial_test::file_serial(model_load, path => "../target/model_load.lock")] #[tokio::test(flavor = "multi_thread")] async fn qwen35_without_mmproj_rejects_image_with_multimodal_not_supported() -> Result<()> { - let cluster = start_in_process_cluster_with_qwen3_5(1, false).await?; + let cluster = start_in_process_cluster_with_qwen3_5(AgentConfig::single(1), false).await?; let inference_client = InferenceHttpClient::new(Client::new(), cluster.addresses.inference_base_url()?); diff --git a/paddler_tests/tests/qwen36_internal_endpoint_emits_reasoning_tokens_for_image_request.rs b/paddler_tests/tests/qwen36_internal_endpoint_emits_reasoning_tokens_for_image_request.rs index 469380a1..c691ac7b 100644 --- a/paddler_tests/tests/qwen36_internal_endpoint_emits_reasoning_tokens_for_image_request.rs +++ b/paddler_tests/tests/qwen36_internal_endpoint_emits_reasoning_tokens_for_image_request.rs @@ -1,6 +1,7 @@ #![cfg(feature = "tests_that_use_llms")] use anyhow::Result; +use paddler_tests::agent_config::AgentConfig; use paddler_tests::collect_generated_tokens::collect_generated_tokens; use paddler_tests::inference_http_client::InferenceHttpClient; use paddler_tests::load_test_image_data_uri::load_test_image_data_uri; @@ -17,7 +18,7 @@ use reqwest::Client; #[serial_test::file_serial(model_load, path => "../target/model_load.lock")] #[tokio::test(flavor = "multi_thread")] async fn qwen36_internal_endpoint_emits_reasoning_tokens_for_image_request() -> Result<()> { - let cluster = start_in_process_cluster_with_qwen3_6_and_mmproj(1).await?; + let cluster = start_in_process_cluster_with_qwen3_6_and_mmproj(AgentConfig::single(1)).await?; let inference_client = InferenceHttpClient::new(Client::new(), cluster.addresses.inference_base_url()?); diff --git a/paddler_tests/tests/qwen36_internal_endpoint_emits_tool_call_parsed_event.rs b/paddler_tests/tests/qwen36_internal_endpoint_emits_tool_call_parsed_event.rs index d138fa0b..2e530298 100644 --- a/paddler_tests/tests/qwen36_internal_endpoint_emits_tool_call_parsed_event.rs +++ b/paddler_tests/tests/qwen36_internal_endpoint_emits_tool_call_parsed_event.rs @@ -1,6 +1,7 @@ #![cfg(feature = "tests_that_use_llms")] use anyhow::Result; +use paddler_tests::agent_config::AgentConfig; use paddler_tests::collect_generated_tokens::collect_generated_tokens; use paddler_tests::inference_http_client::InferenceHttpClient; use paddler_tests::start_in_process_cluster_with_qwen3_6::start_in_process_cluster_with_qwen3_6; @@ -21,7 +22,7 @@ use serde_json::Value; #[serial_test::file_serial(model_load, path => "../target/model_load.lock")] #[tokio::test(flavor = "multi_thread")] async fn qwen36_internal_endpoint_emits_tool_call_parsed_event() -> Result<()> { - let cluster = start_in_process_cluster_with_qwen3_6(1).await?; + let cluster = start_in_process_cluster_with_qwen3_6(AgentConfig::single(1)).await?; let inference_client = InferenceHttpClient::new(Client::new(), cluster.addresses.inference_base_url()?); diff --git a/paddler_tests/tests/qwen36_internal_endpoint_with_thinking_disabled_emits_only_content_tokens.rs b/paddler_tests/tests/qwen36_internal_endpoint_with_thinking_disabled_emits_only_content_tokens.rs index ae255987..65006fc7 100644 --- a/paddler_tests/tests/qwen36_internal_endpoint_with_thinking_disabled_emits_only_content_tokens.rs +++ b/paddler_tests/tests/qwen36_internal_endpoint_with_thinking_disabled_emits_only_content_tokens.rs @@ -1,6 +1,7 @@ #![cfg(feature = "tests_that_use_llms")] use anyhow::Result; +use paddler_tests::agent_config::AgentConfig; use paddler_tests::collect_generated_tokens::collect_generated_tokens; use paddler_tests::inference_http_client::InferenceHttpClient; use paddler_tests::start_in_process_cluster_with_qwen3_6::start_in_process_cluster_with_qwen3_6; @@ -14,7 +15,7 @@ use reqwest::Client; #[serial_test::file_serial(model_load, path => "../target/model_load.lock")] #[tokio::test(flavor = "multi_thread")] async fn qwen36_internal_endpoint_with_thinking_disabled_emits_only_content_tokens() -> Result<()> { - let cluster = start_in_process_cluster_with_qwen3_6(1).await?; + let cluster = start_in_process_cluster_with_qwen3_6(AgentConfig::single(1)).await?; let inference_client = InferenceHttpClient::new(Client::new(), cluster.addresses.inference_base_url()?); diff --git a/paddler_tests/tests/qwen36_internal_endpoint_with_thinking_enabled_emits_reasoning_tokens.rs b/paddler_tests/tests/qwen36_internal_endpoint_with_thinking_enabled_emits_reasoning_tokens.rs index b98de00c..b0161740 100644 --- a/paddler_tests/tests/qwen36_internal_endpoint_with_thinking_enabled_emits_reasoning_tokens.rs +++ b/paddler_tests/tests/qwen36_internal_endpoint_with_thinking_enabled_emits_reasoning_tokens.rs @@ -1,6 +1,7 @@ #![cfg(feature = "tests_that_use_llms")] use anyhow::Result; +use paddler_tests::agent_config::AgentConfig; use paddler_tests::collect_generated_tokens::collect_generated_tokens; use paddler_tests::inference_http_client::InferenceHttpClient; use paddler_tests::start_in_process_cluster_with_qwen3_6::start_in_process_cluster_with_qwen3_6; @@ -14,7 +15,7 @@ use reqwest::Client; #[serial_test::file_serial(model_load, path => "../target/model_load.lock")] #[tokio::test(flavor = "multi_thread")] async fn qwen36_internal_endpoint_with_thinking_enabled_emits_reasoning_tokens() -> Result<()> { - let cluster = start_in_process_cluster_with_qwen3_6(1).await?; + let cluster = start_in_process_cluster_with_qwen3_6(AgentConfig::single(1)).await?; let inference_client = InferenceHttpClient::new(Client::new(), cluster.addresses.inference_base_url()?); diff --git a/paddler_tests/tests/qwen3_gbnf_grammar_constrains_output_to_yes_or_no.rs b/paddler_tests/tests/qwen3_gbnf_grammar_constrains_output_to_yes_or_no.rs index 6baef0fc..2f77f78d 100644 --- a/paddler_tests/tests/qwen3_gbnf_grammar_constrains_output_to_yes_or_no.rs +++ b/paddler_tests/tests/qwen3_gbnf_grammar_constrains_output_to_yes_or_no.rs @@ -1,6 +1,7 @@ #![cfg(feature = "tests_that_use_llms")] use anyhow::Result; +use paddler_tests::agent_config::AgentConfig; use paddler_tests::collect_generated_tokens::collect_generated_tokens; use paddler_tests::inference_http_client::InferenceHttpClient; use paddler_tests::start_in_process_cluster_with_qwen3::start_in_process_cluster_with_qwen3; @@ -11,7 +12,7 @@ use reqwest::Client; #[serial_test::file_serial(model_load, path => "../target/model_load.lock")] #[tokio::test(flavor = "multi_thread")] async fn qwen3_gbnf_grammar_constrains_output_to_yes_or_no() -> Result<()> { - let cluster = start_in_process_cluster_with_qwen3(1).await?; + let cluster = start_in_process_cluster_with_qwen3(AgentConfig::single(1)).await?; let inference_client = InferenceHttpClient::new(Client::new(), cluster.addresses.inference_base_url()?); diff --git a/paddler_tests/tests/qwen3_generates_tokens_from_conversation_history.rs b/paddler_tests/tests/qwen3_generates_tokens_from_conversation_history.rs index 4b27d60c..afb3c816 100644 --- a/paddler_tests/tests/qwen3_generates_tokens_from_conversation_history.rs +++ b/paddler_tests/tests/qwen3_generates_tokens_from_conversation_history.rs @@ -1,6 +1,7 @@ #![cfg(feature = "tests_that_use_llms")] use anyhow::Result; +use paddler_tests::agent_config::AgentConfig; use paddler_tests::collect_generated_tokens::collect_generated_tokens; use paddler_tests::inference_http_client::InferenceHttpClient; use paddler_tests::start_in_process_cluster_with_qwen3::start_in_process_cluster_with_qwen3; @@ -15,7 +16,7 @@ use reqwest::Client; #[serial_test::file_serial(model_load, path => "../target/model_load.lock")] #[tokio::test(flavor = "multi_thread")] async fn qwen3_generates_tokens_from_conversation_history() -> Result<()> { - let cluster = start_in_process_cluster_with_qwen3(1).await?; + let cluster = start_in_process_cluster_with_qwen3(AgentConfig::single(1)).await?; let inference_client = InferenceHttpClient::new(Client::new(), cluster.addresses.inference_base_url()?); diff --git a/paddler_tests/tests/qwen3_generates_tokens_from_raw_prompt.rs b/paddler_tests/tests/qwen3_generates_tokens_from_raw_prompt.rs index eccbd7a9..994d4d04 100644 --- a/paddler_tests/tests/qwen3_generates_tokens_from_raw_prompt.rs +++ b/paddler_tests/tests/qwen3_generates_tokens_from_raw_prompt.rs @@ -1,6 +1,7 @@ #![cfg(feature = "tests_that_use_llms")] use anyhow::Result; +use paddler_tests::agent_config::AgentConfig; use paddler_tests::collect_generated_tokens::collect_generated_tokens; use paddler_tests::inference_http_client::InferenceHttpClient; use paddler_tests::start_in_process_cluster_with_qwen3::start_in_process_cluster_with_qwen3; @@ -12,7 +13,7 @@ use reqwest::Client; #[serial_test::file_serial(model_load, path => "../target/model_load.lock")] #[tokio::test(flavor = "multi_thread")] async fn qwen3_generates_tokens_from_raw_prompt() -> Result<()> { - let cluster = start_in_process_cluster_with_qwen3(1).await?; + let cluster = start_in_process_cluster_with_qwen3(AgentConfig::single(1)).await?; let inference_client = InferenceHttpClient::new(Client::new(), cluster.addresses.inference_base_url()?); diff --git a/paddler_tests/tests/qwen3_grammar_with_thinking_returns_incompatible_error.rs b/paddler_tests/tests/qwen3_grammar_with_thinking_returns_incompatible_error.rs index e941af2d..1d1ce2c0 100644 --- a/paddler_tests/tests/qwen3_grammar_with_thinking_returns_incompatible_error.rs +++ b/paddler_tests/tests/qwen3_grammar_with_thinking_returns_incompatible_error.rs @@ -1,6 +1,7 @@ #![cfg(feature = "tests_that_use_llms")] use anyhow::Result; +use paddler_tests::agent_config::AgentConfig; use paddler_tests::collect_generated_tokens::collect_generated_tokens; use paddler_tests::inference_http_client::InferenceHttpClient; use paddler_tests::start_in_process_cluster_with_qwen3::start_in_process_cluster_with_qwen3; @@ -15,7 +16,7 @@ use reqwest::Client; #[serial_test::file_serial(model_load, path => "../target/model_load.lock")] #[tokio::test(flavor = "multi_thread")] async fn qwen3_grammar_with_thinking_returns_incompatible_error() -> Result<()> { - let cluster = start_in_process_cluster_with_qwen3(1).await?; + let cluster = start_in_process_cluster_with_qwen3(AgentConfig::single(1)).await?; let inference_client = InferenceHttpClient::new(Client::new(), cluster.addresses.inference_base_url()?); diff --git a/paddler_tests/tests/qwen3_internal_endpoint_concurrent_requests_independent_usage.rs b/paddler_tests/tests/qwen3_internal_endpoint_concurrent_requests_independent_usage.rs index 7322ee23..eac8d072 100644 --- a/paddler_tests/tests/qwen3_internal_endpoint_concurrent_requests_independent_usage.rs +++ b/paddler_tests/tests/qwen3_internal_endpoint_concurrent_requests_independent_usage.rs @@ -2,6 +2,7 @@ use anyhow::Result; use futures_util::future; +use paddler_tests::agent_config::AgentConfig; use paddler_tests::collect_generated_tokens::collect_generated_tokens; use paddler_tests::inference_http_client::InferenceHttpClient; use paddler_tests::start_in_process_cluster_with_qwen3::start_in_process_cluster_with_qwen3; @@ -16,7 +17,7 @@ use reqwest::Client; #[serial_test::file_serial(model_load, path => "../target/model_load.lock")] #[tokio::test(flavor = "multi_thread")] async fn qwen3_internal_endpoint_concurrent_requests_keep_independent_usage() -> Result<()> { - let cluster = start_in_process_cluster_with_qwen3(2).await?; + let cluster = start_in_process_cluster_with_qwen3(AgentConfig::single(2)).await?; let inference_client = InferenceHttpClient::new(Client::new(), cluster.addresses.inference_base_url()?); diff --git a/paddler_tests/tests/qwen3_internal_endpoint_emits_tool_call_parsed_event.rs b/paddler_tests/tests/qwen3_internal_endpoint_emits_tool_call_parsed_event.rs index 91101840..d36384e0 100644 --- a/paddler_tests/tests/qwen3_internal_endpoint_emits_tool_call_parsed_event.rs +++ b/paddler_tests/tests/qwen3_internal_endpoint_emits_tool_call_parsed_event.rs @@ -1,6 +1,7 @@ #![cfg(feature = "tests_that_use_llms")] use anyhow::Result; +use paddler_tests::agent_config::AgentConfig; use paddler_tests::collect_generated_tokens::collect_generated_tokens; use paddler_tests::inference_http_client::InferenceHttpClient; use paddler_tests::start_in_process_cluster_with_qwen3::start_in_process_cluster_with_qwen3; @@ -21,7 +22,7 @@ use serde_json::Value; #[serial_test::file_serial(model_load, path => "../target/model_load.lock")] #[tokio::test(flavor = "multi_thread")] async fn qwen3_internal_endpoint_emits_tool_call_parsed_event() -> Result<()> { - let cluster = start_in_process_cluster_with_qwen3(1).await?; + let cluster = start_in_process_cluster_with_qwen3(AgentConfig::single(1)).await?; let inference_client = InferenceHttpClient::new(Client::new(), cluster.addresses.inference_base_url()?); diff --git a/paddler_tests/tests/qwen3_internal_endpoint_emits_tool_call_tokens.rs b/paddler_tests/tests/qwen3_internal_endpoint_emits_tool_call_tokens.rs index 69561133..683e970a 100644 --- a/paddler_tests/tests/qwen3_internal_endpoint_emits_tool_call_tokens.rs +++ b/paddler_tests/tests/qwen3_internal_endpoint_emits_tool_call_tokens.rs @@ -1,6 +1,7 @@ #![cfg(feature = "tests_that_use_llms")] use anyhow::Result; +use paddler_tests::agent_config::AgentConfig; use paddler_tests::collect_generated_tokens::collect_generated_tokens; use paddler_tests::inference_http_client::InferenceHttpClient; use paddler_tests::start_in_process_cluster_with_qwen3::start_in_process_cluster_with_qwen3; @@ -21,7 +22,7 @@ use serde_json::Value; #[serial_test::file_serial(model_load, path => "../target/model_load.lock")] #[tokio::test(flavor = "multi_thread")] async fn qwen3_internal_endpoint_emits_tool_call_tokens() -> Result<()> { - let cluster = start_in_process_cluster_with_qwen3(1).await?; + let cluster = start_in_process_cluster_with_qwen3(AgentConfig::single(1)).await?; let inference_client = InferenceHttpClient::new(Client::new(), cluster.addresses.inference_base_url()?); diff --git a/paddler_tests/tests/qwen3_internal_endpoint_max_tokens_usage_matches.rs b/paddler_tests/tests/qwen3_internal_endpoint_max_tokens_usage_matches.rs index aead2574..66119a7c 100644 --- a/paddler_tests/tests/qwen3_internal_endpoint_max_tokens_usage_matches.rs +++ b/paddler_tests/tests/qwen3_internal_endpoint_max_tokens_usage_matches.rs @@ -1,6 +1,7 @@ #![cfg(feature = "tests_that_use_llms")] use anyhow::Result; +use paddler_tests::agent_config::AgentConfig; use paddler_tests::collect_generated_tokens::collect_generated_tokens; use paddler_tests::inference_http_client::InferenceHttpClient; use paddler_tests::start_in_process_cluster_with_qwen3::start_in_process_cluster_with_qwen3; @@ -16,7 +17,7 @@ const MAX_TOKENS: i32 = 20; #[serial_test::file_serial(model_load, path => "../target/model_load.lock")] #[tokio::test(flavor = "multi_thread")] async fn qwen3_internal_endpoint_max_tokens_usage_matches_streamed_count() -> Result<()> { - let cluster = start_in_process_cluster_with_qwen3(1).await?; + let cluster = start_in_process_cluster_with_qwen3(AgentConfig::single(1)).await?; let inference_client = InferenceHttpClient::new(Client::new(), cluster.addresses.inference_base_url()?); diff --git a/paddler_tests/tests/qwen3_internal_endpoint_pure_content_usage.rs b/paddler_tests/tests/qwen3_internal_endpoint_pure_content_usage.rs index 76094d7f..52254469 100644 --- a/paddler_tests/tests/qwen3_internal_endpoint_pure_content_usage.rs +++ b/paddler_tests/tests/qwen3_internal_endpoint_pure_content_usage.rs @@ -1,6 +1,7 @@ #![cfg(feature = "tests_that_use_llms")] use anyhow::Result; +use paddler_tests::agent_config::AgentConfig; use paddler_tests::collect_generated_tokens::collect_generated_tokens; use paddler_tests::inference_http_client::InferenceHttpClient; use paddler_tests::start_in_process_cluster_with_qwen3::start_in_process_cluster_with_qwen3; @@ -14,7 +15,7 @@ use reqwest::Client; #[serial_test::file_serial(model_load, path => "../target/model_load.lock")] #[tokio::test(flavor = "multi_thread")] async fn qwen3_internal_endpoint_pure_content_usage_breakdown() -> Result<()> { - let cluster = start_in_process_cluster_with_qwen3(1).await?; + let cluster = start_in_process_cluster_with_qwen3(AgentConfig::single(1)).await?; let inference_client = InferenceHttpClient::new(Client::new(), cluster.addresses.inference_base_url()?); diff --git a/paddler_tests/tests/qwen3_internal_endpoint_tools_without_parse_flag_emit_only_raw_tokens.rs b/paddler_tests/tests/qwen3_internal_endpoint_tools_without_parse_flag_emit_only_raw_tokens.rs index cffccaf2..2280be84 100644 --- a/paddler_tests/tests/qwen3_internal_endpoint_tools_without_parse_flag_emit_only_raw_tokens.rs +++ b/paddler_tests/tests/qwen3_internal_endpoint_tools_without_parse_flag_emit_only_raw_tokens.rs @@ -1,6 +1,7 @@ #![cfg(feature = "tests_that_use_llms")] use anyhow::Result; +use paddler_tests::agent_config::AgentConfig; use paddler_tests::collect_generated_tokens::collect_generated_tokens; use paddler_tests::inference_http_client::InferenceHttpClient; use paddler_tests::start_in_process_cluster_with_qwen3::start_in_process_cluster_with_qwen3; @@ -21,7 +22,7 @@ use serde_json::Value; #[serial_test::file_serial(model_load, path => "../target/model_load.lock")] #[tokio::test(flavor = "multi_thread")] async fn qwen3_internal_endpoint_tools_without_parse_flag_emit_only_raw_tokens() -> Result<()> { - let cluster = start_in_process_cluster_with_qwen3(1).await?; + let cluster = start_in_process_cluster_with_qwen3(AgentConfig::single(1)).await?; let inference_client = InferenceHttpClient::new(Client::new(), cluster.addresses.inference_base_url()?); diff --git a/paddler_tests/tests/qwen3_internal_endpoint_with_thinking_disabled_emits_no_reasoning_tokens.rs b/paddler_tests/tests/qwen3_internal_endpoint_with_thinking_disabled_emits_no_reasoning_tokens.rs index c21b1f19..270611b4 100644 --- a/paddler_tests/tests/qwen3_internal_endpoint_with_thinking_disabled_emits_no_reasoning_tokens.rs +++ b/paddler_tests/tests/qwen3_internal_endpoint_with_thinking_disabled_emits_no_reasoning_tokens.rs @@ -1,6 +1,7 @@ #![cfg(feature = "tests_that_use_llms")] use anyhow::Result; +use paddler_tests::agent_config::AgentConfig; use paddler_tests::collect_generated_tokens::collect_generated_tokens; use paddler_tests::inference_http_client::InferenceHttpClient; use paddler_tests::start_in_process_cluster_with_qwen3::start_in_process_cluster_with_qwen3; @@ -14,7 +15,7 @@ use reqwest::Client; #[serial_test::file_serial(model_load, path => "../target/model_load.lock")] #[tokio::test(flavor = "multi_thread")] async fn qwen3_internal_endpoint_with_thinking_disabled_emits_no_reasoning_tokens() -> Result<()> { - let cluster = start_in_process_cluster_with_qwen3(1).await?; + let cluster = start_in_process_cluster_with_qwen3(AgentConfig::single(1)).await?; let inference_client = InferenceHttpClient::new(Client::new(), cluster.addresses.inference_base_url()?); diff --git a/paddler_tests/tests/qwen3_internal_endpoint_with_thinking_enabled_emits_reasoning_tokens.rs b/paddler_tests/tests/qwen3_internal_endpoint_with_thinking_enabled_emits_reasoning_tokens.rs index c7c97f1e..23bb7f11 100644 --- a/paddler_tests/tests/qwen3_internal_endpoint_with_thinking_enabled_emits_reasoning_tokens.rs +++ b/paddler_tests/tests/qwen3_internal_endpoint_with_thinking_enabled_emits_reasoning_tokens.rs @@ -1,6 +1,7 @@ #![cfg(feature = "tests_that_use_llms")] use anyhow::Result; +use paddler_tests::agent_config::AgentConfig; use paddler_tests::collect_generated_tokens::collect_generated_tokens; use paddler_tests::inference_http_client::InferenceHttpClient; use paddler_tests::start_in_process_cluster_with_qwen3::start_in_process_cluster_with_qwen3; @@ -14,7 +15,7 @@ use reqwest::Client; #[serial_test::file_serial(model_load, path => "../target/model_load.lock")] #[tokio::test(flavor = "multi_thread")] async fn qwen3_internal_endpoint_with_thinking_enabled_emits_reasoning_tokens() -> Result<()> { - let cluster = start_in_process_cluster_with_qwen3(1).await?; + let cluster = start_in_process_cluster_with_qwen3(AgentConfig::single(1)).await?; let inference_client = InferenceHttpClient::new(Client::new(), cluster.addresses.inference_base_url()?); diff --git a/paddler_tests/tests/qwen3_json_schema_grammar_returns_valid_json.rs b/paddler_tests/tests/qwen3_json_schema_grammar_returns_valid_json.rs index 9006648b..ef71c2b7 100644 --- a/paddler_tests/tests/qwen3_json_schema_grammar_returns_valid_json.rs +++ b/paddler_tests/tests/qwen3_json_schema_grammar_returns_valid_json.rs @@ -1,6 +1,7 @@ #![cfg(feature = "tests_that_use_llms")] use anyhow::Result; +use paddler_tests::agent_config::AgentConfig; use paddler_tests::collect_generated_tokens::collect_generated_tokens; use paddler_tests::inference_http_client::InferenceHttpClient; use paddler_tests::start_in_process_cluster_with_qwen3::start_in_process_cluster_with_qwen3; @@ -11,7 +12,7 @@ use reqwest::Client; #[serial_test::file_serial(model_load, path => "../target/model_load.lock")] #[tokio::test(flavor = "multi_thread")] async fn qwen3_json_schema_grammar_returns_valid_json() -> Result<()> { - let cluster = start_in_process_cluster_with_qwen3(1).await?; + let cluster = start_in_process_cluster_with_qwen3(AgentConfig::single(1)).await?; let inference_client = InferenceHttpClient::new(Client::new(), cluster.addresses.inference_base_url()?); diff --git a/paddler_tests/tests/qwen3_openai_non_streaming_emits_tool_calls_for_function_tool.rs b/paddler_tests/tests/qwen3_openai_non_streaming_emits_tool_calls_for_function_tool.rs index 1c732ad5..9117d69f 100644 --- a/paddler_tests/tests/qwen3_openai_non_streaming_emits_tool_calls_for_function_tool.rs +++ b/paddler_tests/tests/qwen3_openai_non_streaming_emits_tool_calls_for_function_tool.rs @@ -1,6 +1,7 @@ #![cfg(feature = "tests_that_use_llms")] use anyhow::Result; +use paddler_tests::agent_config::AgentConfig; use paddler_tests::openai_chat_completions_client::OpenAIChatCompletionsClient; use paddler_tests::start_in_process_cluster_with_qwen3::start_in_process_cluster_with_qwen3; use reqwest::Client; @@ -10,7 +11,7 @@ use serde_json::json; #[serial_test::file_serial(model_load, path => "../target/model_load.lock")] #[tokio::test(flavor = "multi_thread")] async fn qwen3_openai_non_streaming_emits_tool_calls_for_function_tool() -> Result<()> { - let cluster = start_in_process_cluster_with_qwen3(1).await?; + let cluster = start_in_process_cluster_with_qwen3(AgentConfig::single(1)).await?; let openai_client = OpenAIChatCompletionsClient::new( Client::new(), &cluster.addresses.compat_openai_base_url()?, diff --git a/paddler_tests/tests/qwen3_openai_non_streaming_returns_usage.rs b/paddler_tests/tests/qwen3_openai_non_streaming_returns_usage.rs index 8627b746..09793da3 100644 --- a/paddler_tests/tests/qwen3_openai_non_streaming_returns_usage.rs +++ b/paddler_tests/tests/qwen3_openai_non_streaming_returns_usage.rs @@ -1,6 +1,7 @@ #![cfg(feature = "tests_that_use_llms")] use anyhow::Result; +use paddler_tests::agent_config::AgentConfig; use paddler_tests::openai_chat_completions_client::OpenAIChatCompletionsClient; use paddler_tests::start_in_process_cluster_with_qwen3::start_in_process_cluster_with_qwen3; use reqwest::Client; @@ -10,7 +11,7 @@ use serde_json::json; #[serial_test::file_serial(model_load, path => "../target/model_load.lock")] #[tokio::test(flavor = "multi_thread")] async fn qwen3_openai_non_streaming_returns_usage() -> Result<()> { - let cluster = start_in_process_cluster_with_qwen3(1).await?; + let cluster = start_in_process_cluster_with_qwen3(AgentConfig::single(1)).await?; let openai_client = OpenAIChatCompletionsClient::new( Client::new(), &cluster.addresses.compat_openai_base_url()?, diff --git a/paddler_tests/tests/qwen3_openai_non_streaming_usage_with_tool_calls.rs b/paddler_tests/tests/qwen3_openai_non_streaming_usage_with_tool_calls.rs index e3a16813..366064bb 100644 --- a/paddler_tests/tests/qwen3_openai_non_streaming_usage_with_tool_calls.rs +++ b/paddler_tests/tests/qwen3_openai_non_streaming_usage_with_tool_calls.rs @@ -1,6 +1,7 @@ #![cfg(feature = "tests_that_use_llms")] use anyhow::Result; +use paddler_tests::agent_config::AgentConfig; use paddler_tests::openai_chat_completions_client::OpenAIChatCompletionsClient; use paddler_tests::start_in_process_cluster_with_qwen3::start_in_process_cluster_with_qwen3; use reqwest::Client; @@ -10,7 +11,7 @@ use serde_json::json; #[serial_test::file_serial(model_load, path => "../target/model_load.lock")] #[tokio::test(flavor = "multi_thread")] async fn qwen3_openai_non_streaming_usage_with_tool_calls() -> Result<()> { - let cluster = start_in_process_cluster_with_qwen3(1).await?; + let cluster = start_in_process_cluster_with_qwen3(AgentConfig::single(1)).await?; let openai_client = OpenAIChatCompletionsClient::new( Client::new(), &cluster.addresses.compat_openai_base_url()?, diff --git a/paddler_tests/tests/qwen3_openai_streaming_emits_tool_calls_for_function_tool.rs b/paddler_tests/tests/qwen3_openai_streaming_emits_tool_calls_for_function_tool.rs index 6d8316ef..dd8dc261 100644 --- a/paddler_tests/tests/qwen3_openai_streaming_emits_tool_calls_for_function_tool.rs +++ b/paddler_tests/tests/qwen3_openai_streaming_emits_tool_calls_for_function_tool.rs @@ -1,6 +1,7 @@ #![cfg(feature = "tests_that_use_llms")] use anyhow::Result; +use paddler_tests::agent_config::AgentConfig; use paddler_tests::openai_chat_completions_client::OpenAIChatCompletionsClient; use paddler_tests::start_in_process_cluster_with_qwen3::start_in_process_cluster_with_qwen3; use reqwest::Client; @@ -10,7 +11,7 @@ use serde_json::json; #[serial_test::file_serial(model_load, path => "../target/model_load.lock")] #[tokio::test(flavor = "multi_thread")] async fn qwen3_openai_streaming_emits_tool_calls_for_function_tool() -> Result<()> { - let cluster = start_in_process_cluster_with_qwen3(1).await?; + let cluster = start_in_process_cluster_with_qwen3(AgentConfig::single(1)).await?; let openai_client = OpenAIChatCompletionsClient::new( Client::new(), &cluster.addresses.compat_openai_base_url()?, diff --git a/paddler_tests/tests/qwen3_openai_streaming_emits_usage_when_requested.rs b/paddler_tests/tests/qwen3_openai_streaming_emits_usage_when_requested.rs index 2b0d0301..fd2afac4 100644 --- a/paddler_tests/tests/qwen3_openai_streaming_emits_usage_when_requested.rs +++ b/paddler_tests/tests/qwen3_openai_streaming_emits_usage_when_requested.rs @@ -1,6 +1,7 @@ #![cfg(feature = "tests_that_use_llms")] use anyhow::Result; +use paddler_tests::agent_config::AgentConfig; use paddler_tests::openai_chat_completions_client::OpenAIChatCompletionsClient; use paddler_tests::start_in_process_cluster_with_qwen3::start_in_process_cluster_with_qwen3; use reqwest::Client; @@ -10,7 +11,7 @@ use serde_json::json; #[serial_test::file_serial(model_load, path => "../target/model_load.lock")] #[tokio::test(flavor = "multi_thread")] async fn qwen3_openai_streaming_emits_usage_when_requested() -> Result<()> { - let cluster = start_in_process_cluster_with_qwen3(1).await?; + let cluster = start_in_process_cluster_with_qwen3(AgentConfig::single(1)).await?; let openai_client = OpenAIChatCompletionsClient::new( Client::new(), &cluster.addresses.compat_openai_base_url()?, diff --git a/paddler_tests/tests/qwen3_openai_streaming_omits_usage_when_not_requested.rs b/paddler_tests/tests/qwen3_openai_streaming_omits_usage_when_not_requested.rs index d36c01bd..1e7e7d4c 100644 --- a/paddler_tests/tests/qwen3_openai_streaming_omits_usage_when_not_requested.rs +++ b/paddler_tests/tests/qwen3_openai_streaming_omits_usage_when_not_requested.rs @@ -1,6 +1,7 @@ #![cfg(feature = "tests_that_use_llms")] use anyhow::Result; +use paddler_tests::agent_config::AgentConfig; use paddler_tests::openai_chat_completions_client::OpenAIChatCompletionsClient; use paddler_tests::start_in_process_cluster_with_qwen3::start_in_process_cluster_with_qwen3; use reqwest::Client; @@ -9,7 +10,7 @@ use serde_json::json; #[serial_test::file_serial(model_load, path => "../target/model_load.lock")] #[tokio::test(flavor = "multi_thread")] async fn qwen3_openai_streaming_omits_usage_when_not_requested() -> Result<()> { - let cluster = start_in_process_cluster_with_qwen3(1).await?; + let cluster = start_in_process_cluster_with_qwen3(AgentConfig::single(1)).await?; let openai_client = OpenAIChatCompletionsClient::new( Client::new(), &cluster.addresses.compat_openai_base_url()?, diff --git a/paddler_tests/tests/qwen3_openai_streaming_routes_reasoning_to_reasoning_content.rs b/paddler_tests/tests/qwen3_openai_streaming_routes_reasoning_to_reasoning_content.rs index 0d997d4a..2fd50824 100644 --- a/paddler_tests/tests/qwen3_openai_streaming_routes_reasoning_to_reasoning_content.rs +++ b/paddler_tests/tests/qwen3_openai_streaming_routes_reasoning_to_reasoning_content.rs @@ -1,6 +1,7 @@ #![cfg(feature = "tests_that_use_llms")] use anyhow::Result; +use paddler_tests::agent_config::AgentConfig; use paddler_tests::openai_chat_completions_client::OpenAIChatCompletionsClient; use paddler_tests::start_in_process_cluster_with_qwen3::start_in_process_cluster_with_qwen3; use reqwest::Client; @@ -10,7 +11,7 @@ use serde_json::json; #[serial_test::file_serial(model_load, path => "../target/model_load.lock")] #[tokio::test(flavor = "multi_thread")] async fn qwen3_openai_streaming_routes_reasoning_to_reasoning_content() -> Result<()> { - let cluster = start_in_process_cluster_with_qwen3(1).await?; + let cluster = start_in_process_cluster_with_qwen3(AgentConfig::single(1)).await?; let openai_client = OpenAIChatCompletionsClient::new( Client::new(), &cluster.addresses.compat_openai_base_url()?, diff --git a/paddler_tests/tests/qwen3_openai_streaming_usage_breakdown_with_thinking.rs b/paddler_tests/tests/qwen3_openai_streaming_usage_breakdown_with_thinking.rs index bac1ec76..227586f8 100644 --- a/paddler_tests/tests/qwen3_openai_streaming_usage_breakdown_with_thinking.rs +++ b/paddler_tests/tests/qwen3_openai_streaming_usage_breakdown_with_thinking.rs @@ -1,6 +1,7 @@ #![cfg(feature = "tests_that_use_llms")] use anyhow::Result; +use paddler_tests::agent_config::AgentConfig; use paddler_tests::openai_chat_completions_client::OpenAIChatCompletionsClient; use paddler_tests::start_in_process_cluster_with_qwen3::start_in_process_cluster_with_qwen3; use reqwest::Client; @@ -10,7 +11,7 @@ use serde_json::json; #[serial_test::file_serial(model_load, path => "../target/model_load.lock")] #[tokio::test(flavor = "multi_thread")] async fn qwen3_openai_streaming_usage_breakdown_with_thinking() -> Result<()> { - let cluster = start_in_process_cluster_with_qwen3(1).await?; + let cluster = start_in_process_cluster_with_qwen3(AgentConfig::single(1)).await?; let openai_client = OpenAIChatCompletionsClient::new( Client::new(), &cluster.addresses.compat_openai_base_url()?, diff --git a/paddler_tests/tests/qwen3_without_grammar_generates_unconstrained_output.rs b/paddler_tests/tests/qwen3_without_grammar_generates_unconstrained_output.rs index 46cc7b44..783ffcdc 100644 --- a/paddler_tests/tests/qwen3_without_grammar_generates_unconstrained_output.rs +++ b/paddler_tests/tests/qwen3_without_grammar_generates_unconstrained_output.rs @@ -1,6 +1,7 @@ #![cfg(feature = "tests_that_use_llms")] use anyhow::Result; +use paddler_tests::agent_config::AgentConfig; use paddler_tests::collect_generated_tokens::collect_generated_tokens; use paddler_tests::inference_http_client::InferenceHttpClient; use paddler_tests::start_in_process_cluster_with_qwen3::start_in_process_cluster_with_qwen3; @@ -10,7 +11,7 @@ use reqwest::Client; #[serial_test::file_serial(model_load, path => "../target/model_load.lock")] #[tokio::test(flavor = "multi_thread")] async fn qwen3_without_grammar_generates_unconstrained_output() -> Result<()> { - let cluster = start_in_process_cluster_with_qwen3(1).await?; + let cluster = start_in_process_cluster_with_qwen3(AgentConfig::single(1)).await?; let inference_client = InferenceHttpClient::new(Client::new(), cluster.addresses.inference_base_url()?); diff --git a/paddler_tests/tests/smolvlm2_generates_tokens_from_image_input.rs b/paddler_tests/tests/smolvlm2_generates_tokens_from_image_input.rs index bcfe7d01..bbfe0466 100644 --- a/paddler_tests/tests/smolvlm2_generates_tokens_from_image_input.rs +++ b/paddler_tests/tests/smolvlm2_generates_tokens_from_image_input.rs @@ -1,6 +1,7 @@ #![cfg(feature = "tests_that_use_llms")] use anyhow::Result; +use paddler_tests::agent_config::AgentConfig; use paddler_tests::collect_generated_tokens::collect_generated_tokens; use paddler_tests::inference_http_client::InferenceHttpClient; use paddler_tests::load_test_image_data_uri::load_test_image_data_uri; @@ -18,7 +19,7 @@ use reqwest::Client; #[serial_test::file_serial(model_load, path => "../target/model_load.lock")] #[tokio::test(flavor = "multi_thread")] async fn smolvlm2_generates_tokens_from_image_input() -> Result<()> { - let cluster = start_in_process_cluster_with_smolvlm2(1).await?; + let cluster = start_in_process_cluster_with_smolvlm2(AgentConfig::single(1)).await?; let inference_client = InferenceHttpClient::new(Client::new(), cluster.addresses.inference_base_url()?); diff --git a/paddler_types/src/embedding_result.rs b/paddler_types/src/embedding_result.rs index b69cc698..8804a21e 100644 --- a/paddler_types/src/embedding_result.rs +++ b/paddler_types/src/embedding_result.rs @@ -2,19 +2,22 @@ use serde::Deserialize; use serde::Serialize; use crate::embedding::Embedding; +use crate::oversized_embedding_document_details::OversizedEmbeddingDocumentDetails; use crate::streamable_result::StreamableResult; #[derive(Debug, Deserialize, Serialize)] #[serde(deny_unknown_fields)] pub enum EmbeddingResult { + DocumentExceedsBatchSize(OversizedEmbeddingDocumentDetails), Done, Embedding(Embedding), + EmbeddingsDisabled, Error(String), } impl StreamableResult for EmbeddingResult { fn is_done(&self) -> bool { - matches!(self, Self::Done | Self::Error(_)) + matches!(self, Self::Done | Self::EmbeddingsDisabled | Self::Error(_),) } } @@ -34,6 +37,22 @@ mod tests { assert!(EmbeddingResult::Error("fail".to_owned()).is_done()); } + #[test] + fn embeddings_disabled_is_done() { + assert!(EmbeddingResult::EmbeddingsDisabled.is_done()); + } + + #[test] + fn document_exceeds_batch_size_is_not_done() { + let result = EmbeddingResult::DocumentExceedsBatchSize(OversizedEmbeddingDocumentDetails { + document_tokens: 4096, + n_batch: 2048, + source_document_id: "huge".to_owned(), + }); + + assert!(!result.is_done()); + } + #[test] fn embedding_is_not_done() { let result = EmbeddingResult::Embedding(Embedding { diff --git a/paddler_types/src/inference_parameters.rs b/paddler_types/src/inference_parameters.rs index b29e394c..63e7a962 100644 --- a/paddler_types/src/inference_parameters.rs +++ b/paddler_types/src/inference_parameters.rs @@ -12,6 +12,7 @@ use crate::validates::Validates; pub struct InferenceParameters { pub n_batch: usize, pub context_size: u32, + pub embedding_batch_size: usize, pub enable_embeddings: bool, pub image_resize_to_fit: u32, pub k_cache_dtype: KvCacheDtype, @@ -42,6 +43,10 @@ impl Validates for InferenceParameters { bail!("image_resize_to_fit must be greater than zero"); } + if self.embedding_batch_size == 0 { + bail!("embedding_batch_size must be greater than zero"); + } + Ok(self) } } @@ -51,6 +56,7 @@ impl Default for InferenceParameters { Self { n_batch: 2048, context_size: 8192, + embedding_batch_size: 256, enable_embeddings: false, image_resize_to_fit: 1024, k_cache_dtype: KvCacheDtype::Q8_0, @@ -89,4 +95,21 @@ mod tests { assert!(params.validate().is_err()); } + + #[test] + fn validate_fails_when_embedding_batch_size_is_zero() { + let params = InferenceParameters { + embedding_batch_size: 0, + ..InferenceParameters::default() + }; + + assert!(params.validate().is_err()); + } + + #[test] + fn default_embedding_batch_size_is_256() { + let params = InferenceParameters::default(); + + assert_eq!(params.embedding_batch_size, 256); + } } diff --git a/paddler_types/src/jsonrpc/error.rs b/paddler_types/src/jsonrpc/error.rs index 9396d0c9..0432e9eb 100644 --- a/paddler_types/src/jsonrpc/error.rs +++ b/paddler_types/src/jsonrpc/error.rs @@ -5,7 +5,7 @@ use std::fmt::Formatter; use serde::Deserialize; use serde::Serialize; -#[derive(Debug, Deserialize, Serialize)] +#[derive(Clone, Debug, Deserialize, Serialize)] #[serde(deny_unknown_fields)] pub struct Error { pub code: i32, diff --git a/paddler_types/src/lib.rs b/paddler_types/src/lib.rs index 78f971ed..e2a333e0 100644 --- a/paddler_types/src/lib.rs +++ b/paddler_types/src/lib.rs @@ -33,6 +33,7 @@ pub mod kv_cache_dtype; pub mod media_marker; pub mod model_metadata; pub mod normalization; +pub mod oversized_embedding_document_details; pub mod oversized_image_details; pub mod pooling_type; pub mod raw_tool_call_tokens; diff --git a/paddler_types/src/oversized_embedding_document_details.rs b/paddler_types/src/oversized_embedding_document_details.rs new file mode 100644 index 00000000..b2fc4096 --- /dev/null +++ b/paddler_types/src/oversized_embedding_document_details.rs @@ -0,0 +1,10 @@ +use serde::Deserialize; +use serde::Serialize; + +#[derive(Debug, Deserialize, Serialize)] +#[serde(deny_unknown_fields)] +pub struct OversizedEmbeddingDocumentDetails { + pub document_tokens: u32, + pub n_batch: u32, + pub source_document_id: String, +} diff --git a/paddler_types/src/request_params/generate_embedding_batch_params/chunk_by_input_size_iter.rs b/paddler_types/src/request_params/generate_embedding_batch_params/chunk_by_input_size_iter.rs deleted file mode 100644 index c793da06..00000000 --- a/paddler_types/src/request_params/generate_embedding_batch_params/chunk_by_input_size_iter.rs +++ /dev/null @@ -1,45 +0,0 @@ -use super::GenerateEmbeddingBatchParams; -use crate::embedding_input_document::EmbeddingInputDocument; -use crate::embedding_normalization_method::EmbeddingNormalizationMethod; - -pub struct ChunkByInputSizeIter<'embedding_batch> { - pub chunk_size: usize, - pub current_index: usize, - pub input_batch: &'embedding_batch [EmbeddingInputDocument], - pub normalization_method: &'embedding_batch EmbeddingNormalizationMethod, -} - -impl Iterator for ChunkByInputSizeIter<'_> { - type Item = GenerateEmbeddingBatchParams; - - fn next(&mut self) -> Option { - if self.current_index >= self.input_batch.len() { - return None; - } - - let mut current_batch = Vec::new(); - let mut current_size = 0; - - while self.current_index < self.input_batch.len() { - let input = &self.input_batch[self.current_index]; - let input_size = input.content.chars().count(); - - if current_size + input_size > self.chunk_size && !current_batch.is_empty() { - break; - } - - current_batch.push(input.clone()); - current_size += input_size; - self.current_index += 1; - } - - if current_batch.is_empty() { - None - } else { - Some(GenerateEmbeddingBatchParams { - input_batch: current_batch, - normalization_method: self.normalization_method.clone(), - }) - } - } -} diff --git a/paddler_types/src/request_params/generate_embedding_batch_params/mod.rs b/paddler_types/src/request_params/generate_embedding_batch_params/mod.rs index 58b4a1e0..1157acc1 100644 --- a/paddler_types/src/request_params/generate_embedding_batch_params/mod.rs +++ b/paddler_types/src/request_params/generate_embedding_batch_params/mod.rs @@ -1,9 +1,6 @@ -mod chunk_by_input_size_iter; - use serde::Deserialize; use serde::Serialize; -use self::chunk_by_input_size_iter::ChunkByInputSizeIter; use crate::embedding_input_document::EmbeddingInputDocument; use crate::embedding_normalization_method::EmbeddingNormalizationMethod; @@ -15,15 +12,47 @@ pub struct GenerateEmbeddingBatchParams { } impl GenerateEmbeddingBatchParams { - /// Input size is the total number of characters in the resulting batches. #[must_use] - pub fn chunk_by_input_size(&self, chunk_size: usize) -> ChunkByInputSizeIter<'_> { - ChunkByInputSizeIter { - input_batch: &self.input_batch, - normalization_method: &self.normalization_method, - chunk_size, - current_index: 0, + pub fn chunk_evenly_with_cap( + &self, + agent_count: usize, + max_documents_per_chunk: usize, + ) -> Vec { + let document_count = self.input_batch.len(); + + if document_count == 0 { + return Vec::new(); } + + let cap = max_documents_per_chunk.max(1); + let agents = agent_count.max(1); + let chunks_to_honor_cap = document_count.div_ceil(cap); + let chunk_count = document_count.min(agents.max(chunks_to_honor_cap)); + + let quotient = document_count / chunk_count; + let remainder = document_count % chunk_count; + + let mut sub_batches = Vec::with_capacity(chunk_count); + let mut start_index = 0; + + for chunk_index in 0..chunk_count { + let chunk_size = if chunk_index < remainder { + quotient + 1 + } else { + quotient + }; + + let end_index = start_index + chunk_size; + + sub_batches.push(GenerateEmbeddingBatchParams { + input_batch: self.input_batch[start_index..end_index].to_vec(), + normalization_method: self.normalization_method.clone(), + }); + + start_index = end_index; + } + + sub_batches } } @@ -45,103 +74,278 @@ mod tests { } } + fn make_docs(count: usize) -> Vec { + (0..count) + .map(|index| make_doc(&format!("doc{index:05}"), "x")) + .collect() + } + #[test] - fn test_chunk_by_input_size() { - let params = make_params(vec![ - make_doc("1", "Hello"), - make_doc("2", "World"), - make_doc("3", "This is a test"), - ]); + fn chunk_evenly_with_cap_empty_input() { + let params = make_params(vec![]); - let batches = params.chunk_by_input_size(10).collect::>(); + let sub_batches = params.chunk_evenly_with_cap(4, 256); - assert_eq!(batches.len(), 2); - assert_eq!(batches[0].input_batch.len(), 2); - assert_eq!(batches[0].input_batch[0].id, "1"); - assert_eq!(batches[0].input_batch[1].id, "2"); - assert_eq!(batches[1].input_batch.len(), 1); - assert_eq!(batches[1].input_batch[0].id, "3"); + assert!(sub_batches.is_empty()); } #[test] - fn test_chunk_empty_batch() { - let params = make_params(vec![]); + fn chunk_evenly_with_cap_single_doc_single_agent() { + let params = make_params(vec![make_doc("only", "content")]); + + let sub_batches = params.chunk_evenly_with_cap(1, 256); + + assert_eq!(sub_batches.len(), 1); + assert_eq!(sub_batches[0].input_batch.len(), 1); + assert_eq!(sub_batches[0].input_batch[0].id, "only"); + } + + #[test] + fn chunk_evenly_with_cap_single_doc_many_agents() { + let params = make_params(vec![make_doc("only", "content")]); + + let sub_batches = params.chunk_evenly_with_cap(5, 256); + + assert_eq!(sub_batches.len(), 1); + assert_eq!(sub_batches[0].input_batch.len(), 1); + assert_eq!(sub_batches[0].input_batch[0].id, "only"); + } + + #[test] + fn chunk_evenly_with_cap_more_agents_than_docs_uses_n_chunks() { + let params = make_params(make_docs(3)); + + let sub_batches = params.chunk_evenly_with_cap(5, 256); + + assert_eq!(sub_batches.len(), 3); + for sub_batch in &sub_batches { + assert_eq!(sub_batch.input_batch.len(), 1); + } + } + + #[test] + fn chunk_evenly_with_cap_zero_agents_treated_as_one() { + let params = make_params(make_docs(5)); + + let sub_batches = params.chunk_evenly_with_cap(0, 256); + + assert_eq!(sub_batches.len(), 1); + assert_eq!(sub_batches[0].input_batch.len(), 5); + } + + #[test] + fn chunk_evenly_with_cap_zero_cap_treated_as_one() { + let params = make_params(make_docs(4)); + + let sub_batches = params.chunk_evenly_with_cap(2, 0); + + assert_eq!(sub_batches.len(), 4); + for sub_batch in &sub_batches { + assert_eq!(sub_batch.input_batch.len(), 1); + } + } + + #[test] + fn chunk_evenly_with_cap_below_cap_splits_per_agent() { + let params = make_params(make_docs(4)); - assert!(params.chunk_by_input_size(100).next().is_none()); + let sub_batches = params.chunk_evenly_with_cap(4, 256); + + assert_eq!(sub_batches.len(), 4); + for sub_batch in &sub_batches { + assert_eq!(sub_batch.input_batch.len(), 1); + } + } + + #[test] + fn chunk_evenly_with_cap_below_cap_uneven_split() { + let params = make_params(make_docs(11)); + + let sub_batches = params.chunk_evenly_with_cap(4, 256); + + assert_eq!(sub_batches.len(), 4); + assert_eq!(sub_batches[0].input_batch.len(), 3); + assert_eq!(sub_batches[1].input_batch.len(), 3); + assert_eq!(sub_batches[2].input_batch.len(), 3); + assert_eq!(sub_batches[3].input_batch.len(), 2); + } + + #[test] + fn chunk_evenly_with_cap_user_example_80_docs_4_agents_cap_100() { + let params = make_params(make_docs(80)); + + let sub_batches = params.chunk_evenly_with_cap(4, 100); + + assert_eq!(sub_batches.len(), 4); + for sub_batch in &sub_batches { + assert_eq!(sub_batch.input_batch.len(), 20); + } + } + + #[test] + fn chunk_evenly_with_cap_user_example_1000_docs_4_agents_cap_100() { + let params = make_params(make_docs(1000)); + + let sub_batches = params.chunk_evenly_with_cap(4, 100); + + assert_eq!(sub_batches.len(), 10); + for sub_batch in &sub_batches { + assert_eq!(sub_batch.input_batch.len(), 100); + } + } + + #[test] + fn chunk_evenly_with_cap_at_cap_boundary_uses_agent_count() { + let params = make_params(make_docs(1024)); + + let sub_batches = params.chunk_evenly_with_cap(4, 256); + + assert_eq!(sub_batches.len(), 4); + for sub_batch in &sub_batches { + assert_eq!(sub_batch.input_batch.len(), 256); + } } #[test] - fn test_chunk_single_item_larger_than_chunk_size() { - let params = make_params(vec![make_doc("1", "This content exceeds the chunk limit")]); + fn chunk_evenly_with_cap_above_cap_boundary_creates_extra_chunks() { + let params = make_params(make_docs(2000)); - let batches = params.chunk_by_input_size(5).collect::>(); + let sub_batches = params.chunk_evenly_with_cap(4, 256); - assert_eq!(batches.len(), 1); - assert_eq!(batches[0].input_batch.len(), 1); - assert_eq!(batches[0].input_batch[0].id, "1"); + assert_eq!(sub_batches.len(), 8); + for sub_batch in &sub_batches { + assert_eq!(sub_batch.input_batch.len(), 250); + } } #[test] - fn test_chunk_oversized_item_does_not_merge_with_next() { - let params = make_params(vec![ - make_doc("1", "This is way too long for chunk"), - make_doc("2", "Short"), - ]); + fn chunk_evenly_with_cap_far_above_cap_distributes_evenly() { + let params = make_params(make_docs(1100)); - let batches = params.chunk_by_input_size(5).collect::>(); + let sub_batches = params.chunk_evenly_with_cap(4, 256); - assert_eq!(batches.len(), 2); - assert_eq!(batches[0].input_batch[0].id, "1"); - assert_eq!(batches[1].input_batch[0].id, "2"); + assert_eq!(sub_batches.len(), 5); + for sub_batch in &sub_batches { + assert_eq!(sub_batch.input_batch.len(), 220); + } } #[test] - fn test_chunk_exact_fit() { - let params = make_params(vec![make_doc("1", "12345"), make_doc("2", "67890")]); + fn chunk_evenly_with_cap_extreme_large_n_small_cap() { + let params = make_params(make_docs(10_000)); - // 5 + 5 = 10, exactly the chunk size - let batches = params.chunk_by_input_size(10).collect::>(); + let sub_batches = params.chunk_evenly_with_cap(4, 1); - assert_eq!(batches.len(), 1); - assert_eq!(batches[0].input_batch.len(), 2); + assert_eq!(sub_batches.len(), 10_000); + for sub_batch in &sub_batches { + assert_eq!(sub_batch.input_batch.len(), 1); + } } #[test] - fn test_chunk_one_over_limit_splits() { - let params = make_params(vec![make_doc("1", "12345"), make_doc("2", "678901")]); + fn chunk_evenly_with_cap_extreme_one_doc_per_chunk() { + let params = make_params(make_docs(100)); - // 5 + 6 = 11, exceeds chunk_size of 10 - let batches = params.chunk_by_input_size(10).collect::>(); + let sub_batches = params.chunk_evenly_with_cap(100, 256); - assert_eq!(batches.len(), 2); - assert_eq!(batches[0].input_batch.len(), 1); - assert_eq!(batches[1].input_batch.len(), 1); + assert_eq!(sub_batches.len(), 100); + for sub_batch in &sub_batches { + assert_eq!(sub_batch.input_batch.len(), 1); + } } #[test] - fn test_chunk_preserves_normalization_method() { + fn chunk_evenly_with_cap_no_sub_batch_exceeds_cap_sweep() { + let document_counts: Vec = (0..=50).chain([256, 257, 1000, 2001]).collect(); + let agent_counts: Vec = (0..=8).collect(); + let caps: Vec = vec![1, 2, 4, 100, 256]; + + for &document_count in &document_counts { + for &agent_count in &agent_counts { + for &cap in &caps { + let params = make_params(make_docs(document_count)); + + let sub_batches = params.chunk_evenly_with_cap(agent_count, cap); + + let effective_cap = cap.max(1); + + let total_documents: usize = + sub_batches.iter().map(|sub| sub.input_batch.len()).sum(); + assert_eq!( + total_documents, document_count, + "total documents must equal N (N={document_count}, agents={agent_count}, cap={cap})", + ); + + for sub_batch in &sub_batches { + assert!( + sub_batch.input_batch.len() <= effective_cap, + "sub-batch size {} exceeds cap {} (N={document_count}, agents={agent_count}, cap={cap})", + sub_batch.input_batch.len(), + effective_cap, + ); + } + + let collected_ids: Vec = sub_batches + .iter() + .flat_map(|sub| sub.input_batch.iter().map(|doc| doc.id.clone())) + .collect(); + let expected_ids: Vec = (0..document_count) + .map(|index| format!("doc{index:05}")) + .collect(); + assert_eq!( + collected_ids, expected_ids, + "concatenated IDs must equal original order (N={document_count}, agents={agent_count}, cap={cap})", + ); + + if document_count > 0 { + assert!( + !sub_batches.is_empty(), + "non-empty input must produce at least one sub-batch (N={document_count}, agents={agent_count}, cap={cap})", + ); + for sub_batch in &sub_batches { + assert!( + !sub_batch.input_batch.is_empty(), + "no sub-batch may be empty (N={document_count}, agents={agent_count}, cap={cap})", + ); + } + } else { + assert!(sub_batches.is_empty(), "empty input must produce empty Vec",); + } + } + } + } + } + + #[test] + fn chunk_evenly_with_cap_preserves_normalization_method() { let params = GenerateEmbeddingBatchParams { - input_batch: vec![make_doc("1", "test")], + input_batch: make_docs(8), normalization_method: EmbeddingNormalizationMethod::L2, }; - let batches = params.chunk_by_input_size(100).collect::>(); + let sub_batches = params.chunk_evenly_with_cap(4, 256); - assert!(matches!( - batches[0].normalization_method, - EmbeddingNormalizationMethod::L2 - )); + assert_eq!(sub_batches.len(), 4); + for sub_batch in &sub_batches { + assert!(matches!( + sub_batch.normalization_method, + EmbeddingNormalizationMethod::L2 + )); + } } #[test] - fn test_chunk_counts_unicode_chars_not_bytes() { - // "café" is 4 chars but 5 bytes (é is 2 bytes) - let params = make_params(vec![make_doc("1", "café"), make_doc("2", "naïve")]); + fn chunk_evenly_with_cap_preserves_document_ids_and_order() { + let params = make_params(make_docs(12)); + + let sub_batches = params.chunk_evenly_with_cap(5, 256); - // 4 chars + 5 chars = 9, fits in chunk_size of 9 - let batches = params.chunk_by_input_size(9).collect::>(); + let collected_ids: Vec = sub_batches + .iter() + .flat_map(|sub| sub.input_batch.iter().map(|doc| doc.id.clone())) + .collect(); + let expected_ids: Vec = (0..12).map(|index| format!("doc{index:05}")).collect(); - assert_eq!(batches.len(), 1); - assert_eq!(batches[0].input_batch.len(), 2); + assert_eq!(collected_ids, expected_ids); } } diff --git a/resources/ts/components/AgentListStream.tsx b/resources/ts/components/AgentListStream.tsx index 69dc27cc..b2eceecc 100644 --- a/resources/ts/components/AgentListStream.tsx +++ b/resources/ts/components/AgentListStream.tsx @@ -1,9 +1,9 @@ import React, { useContext } from "react"; +import { AgentsResponseSchema } from "@intentee/paddler-client/schemas/AgentsResponse"; import { PaddlerConfigurationContext } from "../contexts/PaddlerConfigurationContext"; import { useEventSourceUpdates } from "../hooks/useEventSourceUpdates"; import { matchEventSourceUpdateState } from "../matchEventSourceUpdateState"; -import { AgentsResponseSchema } from "@intentee/paddler-client/schemas/AgentsResponse"; import { AgentList } from "./AgentList"; import { agentListStream__placeholder } from "./AgentListStream.module.css"; diff --git a/resources/ts/components/BufferedRequestsStream.tsx b/resources/ts/components/BufferedRequestsStream.tsx index 487afca0..85b43260 100644 --- a/resources/ts/components/BufferedRequestsStream.tsx +++ b/resources/ts/components/BufferedRequestsStream.tsx @@ -1,9 +1,9 @@ import React, { useContext } from "react"; +import { BufferedRequestsResponseSchema } from "@intentee/paddler-client/schemas/BufferedRequestsResponse"; import { PaddlerConfigurationContext } from "../contexts/PaddlerConfigurationContext"; import { useEventSourceUpdates } from "../hooks/useEventSourceUpdates"; import { matchEventSourceUpdateState } from "../matchEventSourceUpdateState"; -import { BufferedRequestsResponseSchema } from "@intentee/paddler-client/schemas/BufferedRequestsResponse"; import { BufferedRequests } from "./BufferedRequests"; import { dashboardSectionStreamLoader } from "./dashboardSectionStreamLoader.module.css"; diff --git a/resources/ts/components/ChangeModelForm.tsx b/resources/ts/components/ChangeModelForm.tsx index b627197f..cfbe1bc7 100644 --- a/resources/ts/components/ChangeModelForm.tsx +++ b/resources/ts/components/ChangeModelForm.tsx @@ -7,11 +7,11 @@ import React, { } from "react"; import { useLocation } from "wouter"; +import { type BalancerDesiredState } from "@intentee/paddler-client/schemas/BalancerDesiredState"; import { ChatTemplateContext } from "../contexts/ChatTemplateContext"; import { InferenceParametersContext } from "../contexts/InferenceParametersContext"; import { PaddlerConfigurationContext } from "../contexts/PaddlerConfigurationContext"; import { useAgentDesiredModelUrl } from "../hooks/useAgentDesiredModelUrl"; -import { type BalancerDesiredState } from "@intentee/paddler-client/schemas/BalancerDesiredState"; import { ChatTemplateBehavior } from "./ChatTemplateBehavior"; import { InferenceParameterCacheDtype } from "./InferenceParameterCacheDtype"; import { InferenceParameterCheckbox } from "./InferenceParameterCheckbox"; @@ -284,6 +284,10 @@ export function ChangeModelForm({ description="You need embeddings for stuff like semantic search, RAG, and more" name="enable_embeddings" /> + ({ +export function InferenceParameterCheckbox({ description, name, }: { description: string; - name: TKey; + name: InferenceParametersBooleanKeys; }) { const { parameters, setParameter } = useContext(InferenceParametersContext); diff --git a/resources/ts/components/InferenceParameterInput.tsx b/resources/ts/components/InferenceParameterInput.tsx index fc9c8ae5..0f6fd524 100644 --- a/resources/ts/components/InferenceParameterInput.tsx +++ b/resources/ts/components/InferenceParameterInput.tsx @@ -1,7 +1,6 @@ import React, { useCallback, useContext, type FormEvent } from "react"; import { InferenceParametersContext } from "../contexts/InferenceParametersContext"; -import { type InferenceParameters } from "@intentee/paddler-client/schemas/InferenceParameters"; import { type InferenceParametersNumberKeys } from "../inferenceParametersFormKeys"; import { inferenceParameterInput, @@ -9,13 +8,12 @@ import { inferenceParameterInput__label, } from "./inferenceParameterInput.module.css"; -// eslint-disable-next-line @typescript-eslint/no-unnecessary-type-parameters -export function InferenceParameterInput({ +export function InferenceParameterInput({ description, name, }: { description: string; - name: TKey; + name: InferenceParametersNumberKeys; }) { const { parameters, setParameter } = useContext(InferenceParametersContext); @@ -23,10 +21,7 @@ export function InferenceParameterInput) { event.preventDefault(); - setParameter( - name, - parseFloat(event.currentTarget.value) as InferenceParameters[TKey], - ); + setParameter(name, parseFloat(event.currentTarget.value)); }, [name, setParameter], ); diff --git a/resources/ts/components/InferenceParameterPoolingType.tsx b/resources/ts/components/InferenceParameterPoolingType.tsx index 1882ba6e..0b83e0c2 100644 --- a/resources/ts/components/InferenceParameterPoolingType.tsx +++ b/resources/ts/components/InferenceParameterPoolingType.tsx @@ -1,7 +1,7 @@ import React, { useCallback, useContext, type ChangeEvent } from "react"; -import { InferenceParametersContext } from "../contexts/InferenceParametersContext"; import { poolingTypes } from "@intentee/paddler-client/schemas/InferenceParameters"; +import { InferenceParametersContext } from "../contexts/InferenceParametersContext"; import { inferenceParameterInput, inferenceParameterInput__disabledHint, diff --git a/resources/ts/components/InferenceParametersContextProvider.tsx b/resources/ts/components/InferenceParametersContextProvider.tsx index a86150e6..b67e2ed4 100644 --- a/resources/ts/components/InferenceParametersContextProvider.tsx +++ b/resources/ts/components/InferenceParametersContextProvider.tsx @@ -1,10 +1,10 @@ import React, { useMemo, useState, type ReactNode } from "react"; +import { type InferenceParameters } from "@intentee/paddler-client/schemas/InferenceParameters"; import { InferenceParametersContext, type InferenceParametersContextValue, } from "../contexts/InferenceParametersContext"; -import { type InferenceParameters } from "@intentee/paddler-client/schemas/InferenceParameters"; export function InferenceParametersContextProvider({ children, diff --git a/resources/ts/components/ModelMetadata.tsx b/resources/ts/components/ModelMetadata.tsx index 79b4485b..f6fa84a7 100644 --- a/resources/ts/components/ModelMetadata.tsx +++ b/resources/ts/components/ModelMetadata.tsx @@ -1,7 +1,7 @@ import React, { useContext } from "react"; -import { ModelMetadataContext } from "../contexts/ModelMetadataContext"; import { type Agent } from "@intentee/paddler-client/schemas/Agent"; +import { ModelMetadataContext } from "../contexts/ModelMetadataContext"; import { ModalWindow } from "./ModalWindow"; import { ModelChatTemplatePreviewButton } from "./ModelChatTemplatePreviewButton"; import { ModelMetadataFocusedParameter } from "./ModelMetadataFocusedParameter"; diff --git a/resources/ts/components/ModelMetadataLoader.tsx b/resources/ts/components/ModelMetadataLoader.tsx index 706cf763..0fa1b3fc 100644 --- a/resources/ts/components/ModelMetadataLoader.tsx +++ b/resources/ts/components/ModelMetadataLoader.tsx @@ -1,8 +1,8 @@ import React from "react"; +import { type Agent } from "@intentee/paddler-client/schemas/Agent"; import { useModelMetadata } from "../hooks/useModelMetadata"; import { matchFetchJsonState } from "../matchFetchJsonState"; -import { type Agent } from "@intentee/paddler-client/schemas/Agent"; import { ModalWindow } from "./ModalWindow"; import { ModelMetadata } from "./ModelMetadata"; import { ModelMetadataContextProvider } from "./ModelMetadataContextProvider"; diff --git a/resources/ts/components/PromptPage.tsx b/resources/ts/components/PromptPage.tsx index 25c6682f..022ce577 100644 --- a/resources/ts/components/PromptPage.tsx +++ b/resources/ts/components/PromptPage.tsx @@ -1,10 +1,10 @@ import React, { useContext } from "react"; +import { webSocketProtocol } from "@intentee/paddler-client/webSocketProtocol"; import { PaddlerConfigurationContext } from "../contexts/PaddlerConfigurationContext"; import { PromptContext } from "../contexts/PromptContext"; import { useWebSocket } from "../hooks/useWebSocket"; import { matchWebSocketState } from "../matchWebSocketState"; -import { webSocketProtocol } from "@intentee/paddler-client/webSocketProtocol"; import { ConversationMessage } from "./ConversationMessage"; import { ConversationMessagePromptGeneratedTokens } from "./ConversationMessagePromptGeneratedTokens"; import { ConversationPromptInput } from "./ConversationPromptInput"; diff --git a/resources/ts/hooks/useEventSourceUpdates.ts b/resources/ts/hooks/useEventSourceUpdates.ts index b68bb6a9..45a79160 100644 --- a/resources/ts/hooks/useEventSourceUpdates.ts +++ b/resources/ts/hooks/useEventSourceUpdates.ts @@ -12,8 +12,9 @@ export function useEventSourceUpdates({ endpoint: string; schema: TSchema; }): EventSourceState { - const [streamState, setEventSourceState] = - useState>(eventSourceInitialState); + const [streamState, setEventSourceState] = useState< + EventSourceState + >(eventSourceInitialState); useEffect( function () { diff --git a/resources/ts/hooks/useFetchJson.ts b/resources/ts/hooks/useFetchJson.ts index 743a2c61..19589d93 100644 --- a/resources/ts/hooks/useFetchJson.ts +++ b/resources/ts/hooks/useFetchJson.ts @@ -15,8 +15,9 @@ export function useFetchJson({ ): null | Promise; responseSchema: TResponseSchema; }): FetchJsonState> { - const [fetchState, setFetchState] = - useState>>(fetchJsonLoadingState); + const [fetchState, setFetchState] = useState< + FetchJsonState> + >(fetchJsonLoadingState); useEffect( function () { diff --git a/resources/ts/hooks/useWebSocket.ts b/resources/ts/hooks/useWebSocket.ts index a76415cc..fe778989 100644 --- a/resources/ts/hooks/useWebSocket.ts +++ b/resources/ts/hooks/useWebSocket.ts @@ -12,9 +12,14 @@ function incrementVersion(version: number): number { return version + 1; } -export function useWebSocket({ endpoint }: { endpoint: string }): WebSocketState { - const [socketState, setSocketState] = - useState(webSocketConnectingState); +export function useWebSocket({ + endpoint, +}: { + endpoint: string; +}): WebSocketState { + const [socketState, setSocketState] = useState( + webSocketConnectingState, + ); const [version, setVersion] = useState(0); const [webSocket, setWebSocket] = useState(null); const reconnectAttempts = useRef(0); From 57ecf9f10425e728edea59f42ed4d31b591c6962 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ma=C5=82gorzata=20Zagajewska?= Date: Tue, 12 May 2026 18:02:01 +0200 Subject: [PATCH 33/51] Reject zero agent count and zero per-chunk cap in chunk_evenly_with_cap with a typed error --- Cargo.lock | 1 + .../api/post_generate_embedding_batch.rs | 16 +- paddler_types/Cargo.toml | 1 + .../chunk_evenly_with_cap_error.rs | 7 + .../generate_embedding_batch_params/mod.rs | 153 +++++++++++------- paddler_types/src/request_params/mod.rs | 1 + 6 files changed, 122 insertions(+), 57 deletions(-) create mode 100644 paddler_types/src/request_params/generate_embedding_batch_params/chunk_evenly_with_cap_error.rs diff --git a/Cargo.lock b/Cargo.lock index dab7e765..54245e5b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5034,6 +5034,7 @@ dependencies = [ "llama-cpp-bindings-types", "serde", "serde_json", + "thiserror 2.0.18", ] [[package]] diff --git a/paddler/src/balancer/inference_service/http_route/api/post_generate_embedding_batch.rs b/paddler/src/balancer/inference_service/http_route/api/post_generate_embedding_batch.rs index a2a9c58c..efd59ecc 100644 --- a/paddler/src/balancer/inference_service/http_route/api/post_generate_embedding_batch.rs +++ b/paddler/src/balancer/inference_service/http_route/api/post_generate_embedding_batch.rs @@ -1,6 +1,7 @@ use actix_web::Error; use actix_web::HttpResponse; use actix_web::Responder; +use actix_web::error::ErrorInternalServerError; use actix_web::error::ErrorNotImplemented; use actix_web::error::ErrorServiceUnavailable; use actix_web::http::header; @@ -19,6 +20,7 @@ use paddler_types::inference_client::Response as OutgoingResponse; use paddler_types::jsonrpc::Error as JsonRpcError; use paddler_types::jsonrpc::ErrorEnvelope; use paddler_types::jsonrpc::ResponseEnvelope; +use paddler_types::request_params::ChunkEvenlyWithCapError; use paddler_types::request_params::GenerateEmbeddingBatchParams; use tokio::sync::mpsc; use tokio::task::JoinSet; @@ -87,10 +89,22 @@ async fn respond( let mut chunk_tasks: JoinSet<()> = JoinSet::new(); - for batch in params + let batches = match params .into_inner() .chunk_evenly_with_cap(agent_count, embedding_batch_size) { + Ok(batches) => batches, + Err(ChunkEvenlyWithCapError::ZeroAgentCount) => { + return Err(ErrorServiceUnavailable("No agents are currently connected")); + } + Err(ChunkEvenlyWithCapError::ZeroMaxDocumentsPerChunk) => { + return Err(ErrorInternalServerError( + "embedding_batch_size is zero despite validation", + )); + } + }; + + for batch in batches { let buffered_request_manager_clone = app_data.buffered_request_manager.clone(); let chunk_tx_clone = chunk_tx.clone(); let connection_close_clone = connection_close.clone(); diff --git a/paddler_types/Cargo.toml b/paddler_types/Cargo.toml index 61030943..e6432612 100644 --- a/paddler_types/Cargo.toml +++ b/paddler_types/Cargo.toml @@ -14,6 +14,7 @@ jsonschema = { workspace = true } llama-cpp-bindings-types = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } +thiserror = { workspace = true } [lints] workspace = true diff --git a/paddler_types/src/request_params/generate_embedding_batch_params/chunk_evenly_with_cap_error.rs b/paddler_types/src/request_params/generate_embedding_batch_params/chunk_evenly_with_cap_error.rs new file mode 100644 index 00000000..0e42e2c6 --- /dev/null +++ b/paddler_types/src/request_params/generate_embedding_batch_params/chunk_evenly_with_cap_error.rs @@ -0,0 +1,7 @@ +#[derive(Debug, thiserror::Error)] +pub enum ChunkEvenlyWithCapError { + #[error("agent_count must be non-zero")] + ZeroAgentCount, + #[error("max_documents_per_chunk must be non-zero")] + ZeroMaxDocumentsPerChunk, +} diff --git a/paddler_types/src/request_params/generate_embedding_batch_params/mod.rs b/paddler_types/src/request_params/generate_embedding_batch_params/mod.rs index 1157acc1..b9a0fc42 100644 --- a/paddler_types/src/request_params/generate_embedding_batch_params/mod.rs +++ b/paddler_types/src/request_params/generate_embedding_batch_params/mod.rs @@ -1,6 +1,9 @@ +mod chunk_evenly_with_cap_error; + use serde::Deserialize; use serde::Serialize; +pub use self::chunk_evenly_with_cap_error::ChunkEvenlyWithCapError; use crate::embedding_input_document::EmbeddingInputDocument; use crate::embedding_normalization_method::EmbeddingNormalizationMethod; @@ -12,22 +15,26 @@ pub struct GenerateEmbeddingBatchParams { } impl GenerateEmbeddingBatchParams { - #[must_use] pub fn chunk_evenly_with_cap( &self, agent_count: usize, max_documents_per_chunk: usize, - ) -> Vec { + ) -> Result, ChunkEvenlyWithCapError> { + if agent_count == 0 { + return Err(ChunkEvenlyWithCapError::ZeroAgentCount); + } + if max_documents_per_chunk == 0 { + return Err(ChunkEvenlyWithCapError::ZeroMaxDocumentsPerChunk); + } + let document_count = self.input_batch.len(); if document_count == 0 { - return Vec::new(); + return Ok(Vec::new()); } - let cap = max_documents_per_chunk.max(1); - let agents = agent_count.max(1); - let chunks_to_honor_cap = document_count.div_ceil(cap); - let chunk_count = document_count.min(agents.max(chunks_to_honor_cap)); + let chunks_to_honor_cap = document_count.div_ceil(max_documents_per_chunk); + let chunk_count = document_count.min(agent_count.max(chunks_to_honor_cap)); let quotient = document_count / chunk_count; let remainder = document_count % chunk_count; @@ -44,7 +51,7 @@ impl GenerateEmbeddingBatchParams { let end_index = start_index + chunk_size; - sub_batches.push(GenerateEmbeddingBatchParams { + sub_batches.push(Self { input_batch: self.input_batch[start_index..end_index].to_vec(), normalization_method: self.normalization_method.clone(), }); @@ -52,12 +59,14 @@ impl GenerateEmbeddingBatchParams { start_index = end_index; } - sub_batches + Ok(sub_batches) } } #[cfg(test)] mod tests { + use anyhow::Result; + use super::*; fn make_doc(id: &str, content: &str) -> EmbeddingInputDocument { @@ -81,183 +90,211 @@ mod tests { } #[test] - fn chunk_evenly_with_cap_empty_input() { + fn chunk_evenly_with_cap_empty_input() -> Result<()> { let params = make_params(vec![]); - let sub_batches = params.chunk_evenly_with_cap(4, 256); + let sub_batches = params.chunk_evenly_with_cap(4, 256)?; assert!(sub_batches.is_empty()); + + Ok(()) } #[test] - fn chunk_evenly_with_cap_single_doc_single_agent() { + fn chunk_evenly_with_cap_single_doc_single_agent() -> Result<()> { let params = make_params(vec![make_doc("only", "content")]); - let sub_batches = params.chunk_evenly_with_cap(1, 256); + let sub_batches = params.chunk_evenly_with_cap(1, 256)?; assert_eq!(sub_batches.len(), 1); assert_eq!(sub_batches[0].input_batch.len(), 1); assert_eq!(sub_batches[0].input_batch[0].id, "only"); + + Ok(()) } #[test] - fn chunk_evenly_with_cap_single_doc_many_agents() { + fn chunk_evenly_with_cap_single_doc_many_agents() -> Result<()> { let params = make_params(vec![make_doc("only", "content")]); - let sub_batches = params.chunk_evenly_with_cap(5, 256); + let sub_batches = params.chunk_evenly_with_cap(5, 256)?; assert_eq!(sub_batches.len(), 1); assert_eq!(sub_batches[0].input_batch.len(), 1); assert_eq!(sub_batches[0].input_batch[0].id, "only"); + + Ok(()) } #[test] - fn chunk_evenly_with_cap_more_agents_than_docs_uses_n_chunks() { + fn chunk_evenly_with_cap_more_agents_than_docs_uses_n_chunks() -> Result<()> { let params = make_params(make_docs(3)); - let sub_batches = params.chunk_evenly_with_cap(5, 256); + let sub_batches = params.chunk_evenly_with_cap(5, 256)?; assert_eq!(sub_batches.len(), 3); for sub_batch in &sub_batches { assert_eq!(sub_batch.input_batch.len(), 1); } + + Ok(()) } #[test] - fn chunk_evenly_with_cap_zero_agents_treated_as_one() { + fn chunk_evenly_with_cap_rejects_zero_agent_count() { let params = make_params(make_docs(5)); - let sub_batches = params.chunk_evenly_with_cap(0, 256); + let result = params.chunk_evenly_with_cap(0, 256); - assert_eq!(sub_batches.len(), 1); - assert_eq!(sub_batches[0].input_batch.len(), 5); + assert!(matches!( + result, + Err(ChunkEvenlyWithCapError::ZeroAgentCount) + )); } #[test] - fn chunk_evenly_with_cap_zero_cap_treated_as_one() { + fn chunk_evenly_with_cap_rejects_zero_max_documents_per_chunk() { let params = make_params(make_docs(4)); - let sub_batches = params.chunk_evenly_with_cap(2, 0); + let result = params.chunk_evenly_with_cap(2, 0); - assert_eq!(sub_batches.len(), 4); - for sub_batch in &sub_batches { - assert_eq!(sub_batch.input_batch.len(), 1); - } + assert!(matches!( + result, + Err(ChunkEvenlyWithCapError::ZeroMaxDocumentsPerChunk) + )); } #[test] - fn chunk_evenly_with_cap_below_cap_splits_per_agent() { + fn chunk_evenly_with_cap_below_cap_splits_per_agent() -> Result<()> { let params = make_params(make_docs(4)); - let sub_batches = params.chunk_evenly_with_cap(4, 256); + let sub_batches = params.chunk_evenly_with_cap(4, 256)?; assert_eq!(sub_batches.len(), 4); for sub_batch in &sub_batches { assert_eq!(sub_batch.input_batch.len(), 1); } + + Ok(()) } #[test] - fn chunk_evenly_with_cap_below_cap_uneven_split() { + fn chunk_evenly_with_cap_below_cap_uneven_split() -> Result<()> { let params = make_params(make_docs(11)); - let sub_batches = params.chunk_evenly_with_cap(4, 256); + let sub_batches = params.chunk_evenly_with_cap(4, 256)?; assert_eq!(sub_batches.len(), 4); assert_eq!(sub_batches[0].input_batch.len(), 3); assert_eq!(sub_batches[1].input_batch.len(), 3); assert_eq!(sub_batches[2].input_batch.len(), 3); assert_eq!(sub_batches[3].input_batch.len(), 2); + + Ok(()) } #[test] - fn chunk_evenly_with_cap_user_example_80_docs_4_agents_cap_100() { + fn chunk_evenly_with_cap_user_example_80_docs_4_agents_cap_100() -> Result<()> { let params = make_params(make_docs(80)); - let sub_batches = params.chunk_evenly_with_cap(4, 100); + let sub_batches = params.chunk_evenly_with_cap(4, 100)?; assert_eq!(sub_batches.len(), 4); for sub_batch in &sub_batches { assert_eq!(sub_batch.input_batch.len(), 20); } + + Ok(()) } #[test] - fn chunk_evenly_with_cap_user_example_1000_docs_4_agents_cap_100() { + fn chunk_evenly_with_cap_user_example_1000_docs_4_agents_cap_100() -> Result<()> { let params = make_params(make_docs(1000)); - let sub_batches = params.chunk_evenly_with_cap(4, 100); + let sub_batches = params.chunk_evenly_with_cap(4, 100)?; assert_eq!(sub_batches.len(), 10); for sub_batch in &sub_batches { assert_eq!(sub_batch.input_batch.len(), 100); } + + Ok(()) } #[test] - fn chunk_evenly_with_cap_at_cap_boundary_uses_agent_count() { + fn chunk_evenly_with_cap_at_cap_boundary_uses_agent_count() -> Result<()> { let params = make_params(make_docs(1024)); - let sub_batches = params.chunk_evenly_with_cap(4, 256); + let sub_batches = params.chunk_evenly_with_cap(4, 256)?; assert_eq!(sub_batches.len(), 4); for sub_batch in &sub_batches { assert_eq!(sub_batch.input_batch.len(), 256); } + + Ok(()) } #[test] - fn chunk_evenly_with_cap_above_cap_boundary_creates_extra_chunks() { + fn chunk_evenly_with_cap_above_cap_boundary_creates_extra_chunks() -> Result<()> { let params = make_params(make_docs(2000)); - let sub_batches = params.chunk_evenly_with_cap(4, 256); + let sub_batches = params.chunk_evenly_with_cap(4, 256)?; assert_eq!(sub_batches.len(), 8); for sub_batch in &sub_batches { assert_eq!(sub_batch.input_batch.len(), 250); } + + Ok(()) } #[test] - fn chunk_evenly_with_cap_far_above_cap_distributes_evenly() { + fn chunk_evenly_with_cap_far_above_cap_distributes_evenly() -> Result<()> { let params = make_params(make_docs(1100)); - let sub_batches = params.chunk_evenly_with_cap(4, 256); + let sub_batches = params.chunk_evenly_with_cap(4, 256)?; assert_eq!(sub_batches.len(), 5); for sub_batch in &sub_batches { assert_eq!(sub_batch.input_batch.len(), 220); } + + Ok(()) } #[test] - fn chunk_evenly_with_cap_extreme_large_n_small_cap() { + fn chunk_evenly_with_cap_extreme_large_n_small_cap() -> Result<()> { let params = make_params(make_docs(10_000)); - let sub_batches = params.chunk_evenly_with_cap(4, 1); + let sub_batches = params.chunk_evenly_with_cap(4, 1)?; assert_eq!(sub_batches.len(), 10_000); for sub_batch in &sub_batches { assert_eq!(sub_batch.input_batch.len(), 1); } + + Ok(()) } #[test] - fn chunk_evenly_with_cap_extreme_one_doc_per_chunk() { + fn chunk_evenly_with_cap_extreme_one_doc_per_chunk() -> Result<()> { let params = make_params(make_docs(100)); - let sub_batches = params.chunk_evenly_with_cap(100, 256); + let sub_batches = params.chunk_evenly_with_cap(100, 256)?; assert_eq!(sub_batches.len(), 100); for sub_batch in &sub_batches { assert_eq!(sub_batch.input_batch.len(), 1); } + + Ok(()) } #[test] - fn chunk_evenly_with_cap_no_sub_batch_exceeds_cap_sweep() { + fn chunk_evenly_with_cap_no_sub_batch_exceeds_cap_sweep() -> Result<()> { let document_counts: Vec = (0..=50).chain([256, 257, 1000, 2001]).collect(); - let agent_counts: Vec = (0..=8).collect(); + let agent_counts: Vec = (1..=8).collect(); let caps: Vec = vec![1, 2, 4, 100, 256]; for &document_count in &document_counts { @@ -265,9 +302,7 @@ mod tests { for &cap in &caps { let params = make_params(make_docs(document_count)); - let sub_batches = params.chunk_evenly_with_cap(agent_count, cap); - - let effective_cap = cap.max(1); + let sub_batches = params.chunk_evenly_with_cap(agent_count, cap)?; let total_documents: usize = sub_batches.iter().map(|sub| sub.input_batch.len()).sum(); @@ -278,10 +313,10 @@ mod tests { for sub_batch in &sub_batches { assert!( - sub_batch.input_batch.len() <= effective_cap, + sub_batch.input_batch.len() <= cap, "sub-batch size {} exceeds cap {} (N={document_count}, agents={agent_count}, cap={cap})", sub_batch.input_batch.len(), - effective_cap, + cap, ); } @@ -314,16 +349,18 @@ mod tests { } } } + + Ok(()) } #[test] - fn chunk_evenly_with_cap_preserves_normalization_method() { + fn chunk_evenly_with_cap_preserves_normalization_method() -> Result<()> { let params = GenerateEmbeddingBatchParams { input_batch: make_docs(8), normalization_method: EmbeddingNormalizationMethod::L2, }; - let sub_batches = params.chunk_evenly_with_cap(4, 256); + let sub_batches = params.chunk_evenly_with_cap(4, 256)?; assert_eq!(sub_batches.len(), 4); for sub_batch in &sub_batches { @@ -332,13 +369,15 @@ mod tests { EmbeddingNormalizationMethod::L2 )); } + + Ok(()) } #[test] - fn chunk_evenly_with_cap_preserves_document_ids_and_order() { + fn chunk_evenly_with_cap_preserves_document_ids_and_order() -> Result<()> { let params = make_params(make_docs(12)); - let sub_batches = params.chunk_evenly_with_cap(5, 256); + let sub_batches = params.chunk_evenly_with_cap(5, 256)?; let collected_ids: Vec = sub_batches .iter() @@ -347,5 +386,7 @@ mod tests { let expected_ids: Vec = (0..12).map(|index| format!("doc{index:05}")).collect(); assert_eq!(collected_ids, expected_ids); + + Ok(()) } } diff --git a/paddler_types/src/request_params/mod.rs b/paddler_types/src/request_params/mod.rs index cb103d48..dda6195e 100644 --- a/paddler_types/src/request_params/mod.rs +++ b/paddler_types/src/request_params/mod.rs @@ -3,4 +3,5 @@ mod continue_from_raw_prompt_params; mod generate_embedding_batch_params; pub use continue_from_raw_prompt_params::ContinueFromRawPromptParams; +pub use generate_embedding_batch_params::ChunkEvenlyWithCapError; pub use generate_embedding_batch_params::GenerateEmbeddingBatchParams; From 647c2ae0ef13e663cb65d8bd79f926a40b8f180d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ma=C5=82gorzata=20Zagajewska?= Date: Tue, 12 May 2026 18:02:06 +0200 Subject: [PATCH 34/51] Resolve preexisting clippy warnings: box large arbiter variant, annotate iced Message, collapse nested if --- paddler/src/agent/continuous_batch_arbiter.rs | 4 ++-- .../agent/continuous_batch_arbiter_build_outcome.rs | 2 +- paddler_gui/src/start_balancer_form_handler.rs | 4 ++++ paddler_tests/src/start_in_process_cluster.rs | 10 ++++------ 4 files changed, 11 insertions(+), 9 deletions(-) diff --git a/paddler/src/agent/continuous_batch_arbiter.rs b/paddler/src/agent/continuous_batch_arbiter.rs index b010488e..80732c60 100644 --- a/paddler/src/agent/continuous_batch_arbiter.rs +++ b/paddler/src/agent/continuous_batch_arbiter.rs @@ -66,7 +66,7 @@ impl ContinuousBatchArbiter { let model_path_string = model_path.display().to_string(); - ContinuousBatchArbiterBuildOutcome::ReadyToSpawn(Self { + ContinuousBatchArbiterBuildOutcome::ReadyToSpawn(Box::new(Self { agent_name, chat_template_override: applicable_state.chat_template_override, desired_slots_total, @@ -76,7 +76,7 @@ impl ContinuousBatchArbiter { model_path, model_path_string, slot_aggregated_status_manager, - }) + })) } pub async fn spawn(&self) -> Result { diff --git a/paddler/src/agent/continuous_batch_arbiter_build_outcome.rs b/paddler/src/agent/continuous_batch_arbiter_build_outcome.rs index 84b084ad..1abd91ba 100644 --- a/paddler/src/agent/continuous_batch_arbiter_build_outcome.rs +++ b/paddler/src/agent/continuous_batch_arbiter_build_outcome.rs @@ -2,5 +2,5 @@ use crate::agent::continuous_batch_arbiter::ContinuousBatchArbiter; pub enum ContinuousBatchArbiterBuildOutcome { NoModelConfigured, - ReadyToSpawn(ContinuousBatchArbiter), + ReadyToSpawn(Box), } diff --git a/paddler_gui/src/start_balancer_form_handler.rs b/paddler_gui/src/start_balancer_form_handler.rs index 051faaf6..f3254518 100644 --- a/paddler_gui/src/start_balancer_form_handler.rs +++ b/paddler_gui/src/start_balancer_form_handler.rs @@ -41,6 +41,10 @@ fn validate_required_address(raw: &str) -> Result { validate_optional_address(raw)?.ok_or_else(|| "Address is required.".to_owned()) } +#[expect( + clippy::large_enum_variant, + reason = "ephemeral value, immediately consumed" +)] #[derive(Debug, Clone)] pub enum Message { SetBalancerAddress(String), diff --git a/paddler_tests/src/start_in_process_cluster.rs b/paddler_tests/src/start_in_process_cluster.rs index 68dee470..6e679c10 100644 --- a/paddler_tests/src/start_in_process_cluster.rs +++ b/paddler_tests/src/start_in_process_cluster.rs @@ -105,12 +105,10 @@ pub async fn start_in_process_cluster( .map(|registered_agent| registered_agent.id.clone()) .collect(); - if wait_for_slots_ready { - if let Some(agent_config) = agent.as_ref() { - agents_watcher - .wait_for_slots_ready(&[agent_config.slot_count]) - .await?; - } + if wait_for_slots_ready && let Some(agent_config) = agent.as_ref() { + agents_watcher + .wait_for_slots_ready(&[agent_config.slot_count]) + .await?; } Ok(ClusterHandle::new(ClusterHandleParams { From 89c4dc6529dff3e978cc841aa94bc340cd52b770 Mon Sep 17 00:00:00 2001 From: Mateusz Charytoniuk Date: Tue, 12 May 2026 18:15:34 +0200 Subject: [PATCH 35/51] Migrate to LlamaContext::from_model after bindings cycle break --- Cargo.lock | 17 +++++++++-------- paddler/src/agent/continuous_batch_arbiter.rs | 4 ++-- 2 files changed, 11 insertions(+), 10 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 54245e5b..70e65ce6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1123,9 +1123,9 @@ dependencies = [ [[package]] name = "cc" -version = "1.2.59" +version = "1.2.58" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b7a4d3ec6524d28a329fc53654bbadc9bdd7b0431f5d65f1a56ffb28a1ee5283" +checksum = "e1e928d4b69e3077709075a938a05ffbedfa53a84c8f766efbf8220bb1ff60e1" dependencies = [ "find-msvc-tools", "jobserver", @@ -1739,9 +1739,9 @@ dependencies = [ [[package]] name = "derivre" -version = "0.3.11" +version = "0.3.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dd3bf7087923a80510e6ea986960cb4011bcf54259ecb19a80bf40a645a1526c" +checksum = "786c7c65c4ef0c7deb05de3005e01991612a8f09fe0844fc0969c68b90468ba8" dependencies = [ "ahash", "anyhow", @@ -3781,6 +3781,7 @@ dependencies = [ "cmake", "find_cuda_helper", "glob", + "thiserror 2.0.18", "walkdir", ] @@ -3802,9 +3803,9 @@ dependencies = [ [[package]] name = "llguidance" -version = "1.7.5" +version = "1.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5baa07a0af9806dc6b051fbaf665362314415c4eaa9471acc47de3a6113b9479" +checksum = "586ccb282d83fe337ddad2e28bb11bb258a462caa80e117fc76a6c73dac919e4" dependencies = [ "anyhow", "derivre", @@ -7175,9 +7176,9 @@ dependencies = [ [[package]] name = "toktrie" -version = "1.7.5" +version = "1.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dd0aad1688badacc3a769d7bb38b0f668ef4887bff73aa2d5344d407d8e2f4cb" +checksum = "0310776df61c26b98e6ed7c3629de5131e0c8731439350b79152b7f52468229a" dependencies = [ "anyhow", "bytemuck", diff --git a/paddler/src/agent/continuous_batch_arbiter.rs b/paddler/src/agent/continuous_batch_arbiter.rs index 80732c60..e4410241 100644 --- a/paddler/src/agent/continuous_batch_arbiter.rs +++ b/paddler/src/agent/continuous_batch_arbiter.rs @@ -9,6 +9,7 @@ use anyhow::Context as _; use anyhow::Result; use anyhow::anyhow; use llama_cpp_bindings::SampledToken; +use llama_cpp_bindings::context::LlamaContext; use llama_cpp_bindings::context::params::LlamaContextParams; use llama_cpp_bindings::llama_backend::LlamaBackend; use llama_cpp_bindings::model::LlamaModel; @@ -312,8 +313,7 @@ impl ContinuousBatchArbiter { model: model.clone(), }); - let llama_context = match model - .new_context(&llama_backend, context_params) + let llama_context = match LlamaContext::from_model(&model, &llama_backend, context_params) .context("Unable to create llama.cpp context") { Ok(context) => context, From 100bc002457d58ab752438dcb6bd9797b6ba3397 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ma=C5=82gorzata=20Zagajewska?= Date: Tue, 12 May 2026 18:28:01 +0200 Subject: [PATCH 36/51] update paddler_client_python gitignore --- paddler_client_python/.gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/paddler_client_python/.gitignore b/paddler_client_python/.gitignore index c8c7d31d..6b152e7e 100644 --- a/paddler_client_python/.gitignore +++ b/paddler_client_python/.gitignore @@ -8,6 +8,7 @@ build/ *.egg .eggs/ *.whl +.venv .venv/ venv/ .mypy_cache/ From c8fb5d60da96488e139368746f37ed63d3314748 Mon Sep 17 00:00:00 2001 From: Mateusz Charytoniuk Date: Tue, 12 May 2026 20:41:35 +0200 Subject: [PATCH 37/51] Scope Claude rules to source-file paths and add python-on-nixos guidance --- .claude/rules/code-style.md | 8 ++++++++ .claude/rules/python-on-nixos.md | 19 ++++++++++++++++++ .claude/rules/teamwork.md | 8 ++++++++ .claude/rules/testing.md | 8 ++++++++ paddler_client_python/Makefile | 33 -------------------------------- 5 files changed, 43 insertions(+), 33 deletions(-) create mode 100644 .claude/rules/python-on-nixos.md delete mode 100644 paddler_client_python/Makefile diff --git a/.claude/rules/code-style.md b/.claude/rules/code-style.md index 34f774a8..2bdb831a 100644 --- a/.claude/rules/code-style.md +++ b/.claude/rules/code-style.md @@ -1,3 +1,11 @@ +--- +paths: + - "**/*.mjs" + - "**/*.py" + - "**/*.rs" + - "**/*.ts" +--- + # Coding Standards - Keep at most a single public struct per module. diff --git a/.claude/rules/python-on-nixos.md b/.claude/rules/python-on-nixos.md new file mode 100644 index 00000000..04329068 --- /dev/null +++ b/.claude/rules/python-on-nixos.md @@ -0,0 +1,19 @@ +--- +paths: + - "paddler_client_python/**/*" +--- + +# Running Python tooling on NixOS + +To run any Python tool that may have ELF / dynamic-linker issues on NixOS — `ruff`, `mypy`, `pyright`, `pytest`, anything installed from a pip wheel with native +bits — first enter `paddler_client_python/shell.nix`, then drive everything through `poetry` from inside that shell. + +**Why:** +pip wheels like `ruff` ship a generic-linux binary, which NixOS does not provide. +Running them directly fails with `Could not start dynamically linked executable: ... NixOS cannot run dynamically linked executables intended for generic linux environments`. +`shell.nix` provides the Nix-built loader / replacement tools that make those binaries (or their Nix equivalents) actually launch. +`poetry` is just the dispatcher you use *inside* that prepared shell — never the entry point on its own. + +**How to apply:** +- Never invoke `ruff`, `poetry run ...`, `python`, `pytest`, etc. from outside `nix-shell`. If a command starts with one of those, it must be inside `nix-shell --run "..."`. +- If `paddler_client_python/shell.nix` is missing, stop and ask. Adding a `shell.nix` is the fix; running tooling unwrapped is not. diff --git a/.claude/rules/teamwork.md b/.claude/rules/teamwork.md index 34752f56..b5c14e43 100644 --- a/.claude/rules/teamwork.md +++ b/.claude/rules/teamwork.md @@ -1,3 +1,11 @@ +--- +paths: + - "**/*.mjs" + - "**/*.py" + - "**/*.rs" + - "**/*.ts" +--- + # Teamwork and Project Organization Team members own one module each. The project needs to be organized around small self-contained modules. diff --git a/.claude/rules/testing.md b/.claude/rules/testing.md index d24bd31f..901b956f 100644 --- a/.claude/rules/testing.md +++ b/.claude/rules/testing.md @@ -1,3 +1,11 @@ +--- +paths: + - "**/*.mjs" + - "**/*.py" + - "**/*.rs" + - "**/*.ts" +--- + # Unit Tests and Quality Control - Always check that the unit tests pass. diff --git a/paddler_client_python/Makefile b/paddler_client_python/Makefile deleted file mode 100644 index 062f5ee7..00000000 --- a/paddler_client_python/Makefile +++ /dev/null @@ -1,33 +0,0 @@ -.venv: poetry.lock - poetry sync - touch .venv - -poetry.lock: pyproject.toml - poetry lock - touch poetry.lock - -.PHONY: check -check: lint test - -.PHONY: lint -lint: mypy pyright ruff - -.PHONY: mypy -mypy: .venv - poetry run mypy paddler_client tests - -.PHONY: pyright -pyright: .venv - poetry run pyright - -.PHONY: ruff -ruff: .venv - poetry run ruff check paddler_client tests - -.PHONY: fmt -fmt: - poetry run ruff format paddler_client tests - -.PHONY: test -test: .venv - poetry run pytest From 429a24a790c97b16348c5b61e2c82339f99e801f Mon Sep 17 00:00:00 2001 From: Mateusz Charytoniuk Date: Tue, 12 May 2026 20:41:51 +0200 Subject: [PATCH 38/51] Bump Python client ruff to 0.15.12 and refactor GeneratedTokenResult parser; raise TypeError on non-dict/non-list payloads --- .../paddler_client/inference_message.py | 248 +++++++++++------- .../paddler_client/parsed_tool_call.py | 16 +- paddler_client_python/poetry.lock | 40 +-- paddler_client_python/pyproject.toml | 2 +- .../tests/test_inference_message.py | 10 +- .../tests/test_parsed_tool_call.py | 5 +- .../tests/test_tool_call_arguments.py | 8 +- 7 files changed, 201 insertions(+), 128 deletions(-) diff --git a/paddler_client_python/paddler_client/inference_message.py b/paddler_client_python/paddler_client/inference_message.py index 6e1903f6..a6db786d 100644 --- a/paddler_client_python/paddler_client/inference_message.py +++ b/paddler_client_python/paddler_client/inference_message.py @@ -1,10 +1,10 @@ from __future__ import annotations import json +from collections.abc import Callable from dataclasses import dataclass from enum import StrEnum -from typing import Any -from typing import cast +from typing import Any, cast from paddler_client.embedding import Embedding from paddler_client.oversized_image_details import OversizedImageDetails @@ -215,7 +215,9 @@ def _parse_response( "ImageDecodingFailed": InferenceMessageKind.IMAGE_DECODING_FAILED, "MultimodalNotSupported": InferenceMessageKind.MULTIMODAL_NOT_SUPPORTED, "SamplerError": InferenceMessageKind.SAMPLER_ERROR, - "ToolCallValidatorBuildFailed": InferenceMessageKind.TOOL_CALL_VALIDATOR_BUILD_FAILED, + "ToolCallValidatorBuildFailed": ( + InferenceMessageKind.TOOL_CALL_VALIDATOR_BUILD_FAILED + ), } @@ -227,105 +229,171 @@ def _parse_response( } -def _parse_generated_token_result( +def _build_done_message( request_id: str, - data: str | dict[str, Any], + payload: Any, generated_by: str | None, ) -> InferenceMessage: - if not isinstance(data, dict): - msg = f"Unknown GeneratedTokenResult: {data}" - raise ValueError(msg) + return InferenceMessage( + request_id=request_id, + kind=InferenceMessageKind.DONE, + summary=GenerationSummary.from_dict(payload), + generated_by=generated_by, + ) - if "Done" in data: - return InferenceMessage( - request_id=request_id, - kind=InferenceMessageKind.DONE, - summary=GenerationSummary.from_dict(data["Done"]), - generated_by=generated_by, - ) - if "ToolCallParsed" in data: - raw_calls = data["ToolCallParsed"] - if not isinstance(raw_calls, list): - msg = f"ToolCallParsed payload is not a list: {raw_calls}" - raise ValueError(msg) - typed_calls = cast("list[dict[str, Any]]", raw_calls) - parsed_calls: list[ParsedToolCall] = [ - ParsedToolCall.from_dict(call) for call in typed_calls - ] - return InferenceMessage( - request_id=request_id, - kind=InferenceMessageKind.TOOL_CALL_PARSED, - parsed_tool_calls=parsed_calls, - generated_by=generated_by, - ) +def _build_tool_call_parsed_message( + request_id: str, + payload: Any, + generated_by: str | None, +) -> InferenceMessage: + if not isinstance(payload, list): + msg = f"ToolCallParsed payload is not a list: {payload}" + raise TypeError(msg) + typed_calls = cast("list[dict[str, Any]]", payload) + parsed_calls: list[ParsedToolCall] = [ + ParsedToolCall.from_dict(call) for call in typed_calls + ] + return InferenceMessage( + request_id=request_id, + kind=InferenceMessageKind.TOOL_CALL_PARSED, + parsed_tool_calls=parsed_calls, + generated_by=generated_by, + ) - if "ToolCallParseFailed" in data: - return InferenceMessage( - request_id=request_id, - kind=InferenceMessageKind.TOOL_CALL_PARSE_FAILED, - error_message=str(data["ToolCallParseFailed"]), - generated_by=generated_by, - ) - if "ToolCallValidationFailed" in data: - errors = data["ToolCallValidationFailed"] - if not isinstance(errors, list): - msg = f"ToolCallValidationFailed payload is not a list: {errors}" - raise ValueError(msg) - typed_errors = cast("list[object]", errors) - joined_errors: str = "; ".join(str(error) for error in typed_errors) - return InferenceMessage( - request_id=request_id, - kind=InferenceMessageKind.TOOL_CALL_VALIDATION_FAILED, - error_message=joined_errors, - generated_by=generated_by, - ) +def _build_tool_call_parse_failed_message( + request_id: str, + payload: Any, + generated_by: str | None, +) -> InferenceMessage: + return InferenceMessage( + request_id=request_id, + kind=InferenceMessageKind.TOOL_CALL_PARSE_FAILED, + error_message=str(payload), + generated_by=generated_by, + ) - if "UnrecognizedToolCallFormat" in data: - raw_payload = data["UnrecognizedToolCallFormat"] - if not isinstance(raw_payload, dict): - msg = f"UnrecognizedToolCallFormat payload is not a dict: {raw_payload!r}" - raise ValueError(msg) - typed_raw = cast("dict[str, Any]", raw_payload) - return InferenceMessage( - request_id=request_id, - kind=InferenceMessageKind.UNRECOGNIZED_TOOL_CALL_FORMAT, - raw_tool_call_tokens=RawToolCallTokens.from_dict(typed_raw), - generated_by=generated_by, - ) - if "ImageExceedsBatchSize" in data: - details_payload = data["ImageExceedsBatchSize"] - if not isinstance(details_payload, dict): - msg = f"ImageExceedsBatchSize payload is not a dict: {details_payload!r}" - raise ValueError(msg) - typed_details = cast("dict[str, Any]", details_payload) - return InferenceMessage( - request_id=request_id, - kind=InferenceMessageKind.IMAGE_EXCEEDS_BATCH_SIZE, - oversized_image_details=OversizedImageDetails.from_dict(typed_details), - generated_by=generated_by, - ) +def _build_tool_call_validation_failed_message( + request_id: str, + payload: Any, + generated_by: str | None, +) -> InferenceMessage: + if not isinstance(payload, list): + msg = f"ToolCallValidationFailed payload is not a list: {payload}" + raise TypeError(msg) + typed_errors = cast("list[object]", payload) + joined_errors: str = "; ".join(str(error) for error in typed_errors) + return InferenceMessage( + request_id=request_id, + kind=InferenceMessageKind.TOOL_CALL_VALIDATION_FAILED, + error_message=joined_errors, + generated_by=generated_by, + ) - for key, kind in _GENERATED_TOKEN_KINDS.items(): - if key in data: - return InferenceMessage( - request_id=request_id, - kind=kind, - token=data[key], - generated_by=generated_by, - ) - for key, kind in _GENERATED_TOKEN_ERROR_KINDS.items(): - if key in data: - return InferenceMessage( - request_id=request_id, - kind=kind, - error_message=data[key], - generated_by=generated_by, - ) +def _build_unrecognized_tool_call_format_message( + request_id: str, + payload: Any, + generated_by: str | None, +) -> InferenceMessage: + if not isinstance(payload, dict): + msg = f"UnrecognizedToolCallFormat payload is not a dict: {payload!r}" + raise TypeError(msg) + typed_raw = cast("dict[str, Any]", payload) + return InferenceMessage( + request_id=request_id, + kind=InferenceMessageKind.UNRECOGNIZED_TOOL_CALL_FORMAT, + raw_tool_call_tokens=RawToolCallTokens.from_dict(typed_raw), + generated_by=generated_by, + ) + + +def _build_image_exceeds_batch_size_message( + request_id: str, + payload: Any, + generated_by: str | None, +) -> InferenceMessage: + if not isinstance(payload, dict): + msg = f"ImageExceedsBatchSize payload is not a dict: {payload!r}" + raise TypeError(msg) + typed_details = cast("dict[str, Any]", payload) + return InferenceMessage( + request_id=request_id, + kind=InferenceMessageKind.IMAGE_EXCEEDS_BATCH_SIZE, + oversized_image_details=OversizedImageDetails.from_dict(typed_details), + generated_by=generated_by, + ) + + +def _build_token_kind_message( + request_id: str, + kind: InferenceMessageKind, + payload: Any, + generated_by: str | None, +) -> InferenceMessage: + return InferenceMessage( + request_id=request_id, + kind=kind, + token=payload, + generated_by=generated_by, + ) + +def _build_error_kind_message( + request_id: str, + kind: InferenceMessageKind, + payload: Any, + generated_by: str | None, +) -> InferenceMessage: + return InferenceMessage( + request_id=request_id, + kind=kind, + error_message=payload, + generated_by=generated_by, + ) + + +_StructuredHandler = Callable[[str, Any, str | None], InferenceMessage] + +_STRUCTURED_HANDLERS: dict[str, _StructuredHandler] = { + "Done": _build_done_message, + "ToolCallParsed": _build_tool_call_parsed_message, + "ToolCallParseFailed": _build_tool_call_parse_failed_message, + "ToolCallValidationFailed": _build_tool_call_validation_failed_message, + "UnrecognizedToolCallFormat": _build_unrecognized_tool_call_format_message, + "ImageExceedsBatchSize": _build_image_exceeds_batch_size_message, +} + + +def _parse_generated_token_result( + request_id: str, + data: str | dict[str, Any], + generated_by: str | None, +) -> InferenceMessage: + if not isinstance(data, dict): + msg = f"Unknown GeneratedTokenResult: {data}" + raise TypeError(msg) + for structured_key, handler in _STRUCTURED_HANDLERS.items(): + if structured_key in data: + return handler(request_id, data[structured_key], generated_by) + for token_key, token_kind in _GENERATED_TOKEN_KINDS.items(): + if token_key in data: + return _build_token_kind_message( + request_id, + token_kind, + data[token_key], + generated_by, + ) + for error_key, error_kind in _GENERATED_TOKEN_ERROR_KINDS.items(): + if error_key in data: + return _build_error_kind_message( + request_id, + error_kind, + data[error_key], + generated_by, + ) msg = f"Unknown GeneratedTokenResult: {data}" raise ValueError(msg) diff --git a/paddler_client_python/paddler_client/parsed_tool_call.py b/paddler_client_python/paddler_client/parsed_tool_call.py index 9be74f1e..969f2004 100644 --- a/paddler_client_python/paddler_client/parsed_tool_call.py +++ b/paddler_client_python/paddler_client/parsed_tool_call.py @@ -1,11 +1,12 @@ from __future__ import annotations from dataclasses import dataclass -from typing import Any -from typing import cast +from typing import Any, cast -from paddler_client.tool_call_arguments import ToolCallArguments -from paddler_client.tool_call_arguments import parse_tool_call_arguments +from paddler_client.tool_call_arguments import ( + ToolCallArguments, + parse_tool_call_arguments, +) @dataclass(frozen=True) @@ -18,8 +19,11 @@ class ParsedToolCall: def from_dict(cls, data: dict[str, Any]) -> ParsedToolCall: arguments_payload = data["arguments"] if not isinstance(arguments_payload, dict): - msg = f"arguments field must be a dict (tagged enum), got: {arguments_payload!r}" - raise ValueError(msg) + msg = ( + f"arguments field must be a dict (tagged enum), " + f"got: {arguments_payload!r}" + ) + raise TypeError(msg) typed_payload = cast("dict[str, Any]", arguments_payload) return cls( id=str(data["id"]), diff --git a/paddler_client_python/poetry.lock b/paddler_client_python/poetry.lock index 330fda2c..dae3d93c 100644 --- a/paddler_client_python/poetry.lock +++ b/paddler_client_python/poetry.lock @@ -749,30 +749,30 @@ testing = ["process-tests", "pytest-xdist", "virtualenv"] [[package]] name = "ruff" -version = "0.11.13" +version = "0.15.12" description = "An extremely fast Python linter and code formatter, written in Rust." optional = false python-versions = ">=3.7" groups = ["dev"] files = [ - {file = "ruff-0.11.13-py3-none-linux_armv6l.whl", hash = "sha256:4bdfbf1240533f40042ec00c9e09a3aade6f8c10b6414cf11b519488d2635d46"}, - {file = "ruff-0.11.13-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:aef9c9ed1b5ca28bb15c7eac83b8670cf3b20b478195bd49c8d756ba0a36cf48"}, - {file = "ruff-0.11.13-py3-none-macosx_11_0_arm64.whl", hash = "sha256:53b15a9dfdce029c842e9a5aebc3855e9ab7771395979ff85b7c1dedb53ddc2b"}, - {file = "ruff-0.11.13-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ab153241400789138d13f362c43f7edecc0edfffce2afa6a68434000ecd8f69a"}, - {file = "ruff-0.11.13-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:6c51f93029d54a910d3d24f7dd0bb909e31b6cd989a5e4ac513f4eb41629f0dc"}, - {file = "ruff-0.11.13-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1808b3ed53e1a777c2ef733aca9051dc9bf7c99b26ece15cb59a0320fbdbd629"}, - {file = "ruff-0.11.13-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:d28ce58b5ecf0f43c1b71edffabe6ed7f245d5336b17805803312ec9bc665933"}, - {file = "ruff-0.11.13-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:55e4bc3a77842da33c16d55b32c6cac1ec5fb0fbec9c8c513bdce76c4f922165"}, - {file = "ruff-0.11.13-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:633bf2c6f35678c56ec73189ba6fa19ff1c5e4807a78bf60ef487b9dd272cc71"}, - {file = "ruff-0.11.13-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4ffbc82d70424b275b089166310448051afdc6e914fdab90e08df66c43bb5ca9"}, - {file = "ruff-0.11.13-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:4a9ddd3ec62a9a89578c85842b836e4ac832d4a2e0bfaad3b02243f930ceafcc"}, - {file = "ruff-0.11.13-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:d237a496e0778d719efb05058c64d28b757c77824e04ffe8796c7436e26712b7"}, - {file = "ruff-0.11.13-py3-none-musllinux_1_2_i686.whl", hash = "sha256:26816a218ca6ef02142343fd24c70f7cd8c5aa6c203bca284407adf675984432"}, - {file = "ruff-0.11.13-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:51c3f95abd9331dc5b87c47ac7f376db5616041173826dfd556cfe3d4977f492"}, - {file = "ruff-0.11.13-py3-none-win32.whl", hash = "sha256:96c27935418e4e8e77a26bb05962817f28b8ef3843a6c6cc49d8783b5507f250"}, - {file = "ruff-0.11.13-py3-none-win_amd64.whl", hash = "sha256:29c3189895a8a6a657b7af4e97d330c8a3afd2c9c8f46c81e2fc5a31866517e3"}, - {file = "ruff-0.11.13-py3-none-win_arm64.whl", hash = "sha256:b4385285e9179d608ff1d2fb9922062663c658605819a6876d8beef0c30b7f3b"}, - {file = "ruff-0.11.13.tar.gz", hash = "sha256:26fa247dc68d1d4e72c179e08889a25ac0c7ba4d78aecfc835d49cbfd60bf514"}, + {file = "ruff-0.15.12-py3-none-linux_armv6l.whl", hash = "sha256:f86f176e188e94d6bdbc09f09bfd9dc729059ad93d0e7390b5a73efe19f8861c"}, + {file = "ruff-0.15.12-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:e3bcd123364c3770b8e1b7baaf343cc99a35f197c5c6e8af79015c666c423a6c"}, + {file = "ruff-0.15.12-py3-none-macosx_11_0_arm64.whl", hash = "sha256:fe87510d000220aa1ed530d4448a7c696a0cae1213e5ec30e5874287b66557b5"}, + {file = "ruff-0.15.12-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:84a1630093121375a3e2a95b4a6dc7b59e2b4ee76216e32d81aae550a832d002"}, + {file = "ruff-0.15.12-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:fb129f40f114f089ebe0ca56c0d251cf2061b17651d464bb6478dc01e69f11f5"}, + {file = "ruff-0.15.12-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b0c862b172d695db7598426b8af465e7e9ac00a3ea2a3630ee67eb82e366aaa6"}, + {file = "ruff-0.15.12-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:2849ea9f3484c3aca43a82f484210370319e7170df4dfe4843395ddf6c57bc33"}, + {file = "ruff-0.15.12-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9e77c7e51c07fe396826d5969a5b846d9cd4c402535835fb6e21ce8b28fef847"}, + {file = "ruff-0.15.12-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:83b2f4f2f3b1026b5fb449b467d9264bf22067b600f7b6f41fc5958909f449d0"}, + {file = "ruff-0.15.12-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:9ba3b8f1afd7e2e43d8943e55f249e13f9682fde09711644a6e7290eb4f3e339"}, + {file = "ruff-0.15.12-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:e852ba9fdc890655e1d78f2df1499efbe0e54126bd405362154a75e2bde159c5"}, + {file = "ruff-0.15.12-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:dd8aed930da53780d22fc70bdf84452c843cf64f8cb4eb38984319c24c5cd5fd"}, + {file = "ruff-0.15.12-py3-none-musllinux_1_2_i686.whl", hash = "sha256:01da3988d225628b709493d7dc67c3b9b12c0210016b08690ef9bd27970b262b"}, + {file = "ruff-0.15.12-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:9cae0f92bd5700d1213188b31cd3bdd2b315361296d10b96b8e2337d3d11f53e"}, + {file = "ruff-0.15.12-py3-none-win32.whl", hash = "sha256:d0185894e038d7043ba8fd6aee7499ece6462dc0ea9f1e260c7451807c714c20"}, + {file = "ruff-0.15.12-py3-none-win_amd64.whl", hash = "sha256:c87a162d61ab3adca47c03f7f717c68672edec7d1b5499e652331780fe74950d"}, + {file = "ruff-0.15.12-py3-none-win_arm64.whl", hash = "sha256:a538f7a82d061cee7be55542aca1d86d1393d55d81d4fcc314370f4340930d4f"}, + {file = "ruff-0.15.12.tar.gz", hash = "sha256:ecea26adb26b4232c0c2ca19ccbc0083a68344180bba2a600605538ce51a40a6"}, ] [[package]] @@ -884,4 +884,4 @@ files = [ [metadata] lock-version = "2.1" python-versions = "^3.11" -content-hash = "583a72dfa1b3b77080e861788d046f3b896d7e0d4045cfb371fac424ad51d54b" +content-hash = "8ede1b613e2e51eabdae4fd71df2ce16d21bf2c3063b3769d016d9ecf452d629" diff --git a/paddler_client_python/pyproject.toml b/paddler_client_python/pyproject.toml index 910f011a..f6699452 100644 --- a/paddler_client_python/pyproject.toml +++ b/paddler_client_python/pyproject.toml @@ -25,7 +25,7 @@ pytest-asyncio = "^1.3" pytest-cov = "^7" pygments = "^2.20" pyright = "^1" -ruff = "^0.11" +ruff = "0.15.12" mypy = "^1" [build-system] diff --git a/paddler_client_python/tests/test_inference_message.py b/paddler_client_python/tests/test_inference_message.py index cff84440..f971bb3d 100644 --- a/paddler_client_python/tests/test_inference_message.py +++ b/paddler_client_python/tests/test_inference_message.py @@ -122,7 +122,7 @@ def test_parse_tool_call_parsed_with_non_list_payload_raises() -> None: }, } - with pytest.raises(ValueError, match="ToolCallParsed payload is not a list"): + with pytest.raises(TypeError, match="ToolCallParsed payload is not a list"): parse_inference_client_message(data) @@ -135,7 +135,7 @@ def test_parse_tool_call_validation_failed_with_non_list_payload_raises() -> Non } with pytest.raises( - ValueError, + TypeError, match="ToolCallValidationFailed payload is not a list", ): parse_inference_client_message(data) @@ -180,7 +180,7 @@ def test_parse_unrecognized_tool_call_format_with_non_dict_payload_raises() -> N } with pytest.raises( - ValueError, + TypeError, match="UnrecognizedToolCallFormat payload is not a dict", ): parse_inference_client_message(data) @@ -220,7 +220,7 @@ def test_parse_image_exceeds_batch_size_with_non_dict_payload_raises() -> None: } with pytest.raises( - ValueError, + TypeError, match="ImageExceedsBatchSize payload is not a dict", ): parse_inference_client_message(data) @@ -572,7 +572,7 @@ def test_parse_string_generated_token_result_raises() -> None: } } - with pytest.raises(ValueError, match="Unknown GeneratedTokenResult"): + with pytest.raises(TypeError, match="Unknown GeneratedTokenResult"): parse_inference_client_message(data) diff --git a/paddler_client_python/tests/test_parsed_tool_call.py b/paddler_client_python/tests/test_parsed_tool_call.py index a48f7ee0..e2dd0aba 100644 --- a/paddler_client_python/tests/test_parsed_tool_call.py +++ b/paddler_client_python/tests/test_parsed_tool_call.py @@ -1,8 +1,7 @@ import pytest from paddler_client.parsed_tool_call import ParsedToolCall -from paddler_client.tool_call_arguments import InvalidJson -from paddler_client.tool_call_arguments import ValidJson +from paddler_client.tool_call_arguments import InvalidJson, ValidJson def test_from_dict_with_valid_json_arguments() -> None: @@ -32,7 +31,7 @@ def test_from_dict_with_invalid_json_arguments() -> None: def test_from_dict_with_non_dict_arguments_raises() -> None: - with pytest.raises(ValueError, match="arguments field must be a dict"): + with pytest.raises(TypeError, match="arguments field must be a dict"): ParsedToolCall.from_dict( { "id": "x", diff --git a/paddler_client_python/tests/test_tool_call_arguments.py b/paddler_client_python/tests/test_tool_call_arguments.py index c6189bc8..b9df15ed 100644 --- a/paddler_client_python/tests/test_tool_call_arguments.py +++ b/paddler_client_python/tests/test_tool_call_arguments.py @@ -1,8 +1,10 @@ import pytest -from paddler_client.tool_call_arguments import InvalidJson -from paddler_client.tool_call_arguments import ValidJson -from paddler_client.tool_call_arguments import parse_tool_call_arguments +from paddler_client.tool_call_arguments import ( + InvalidJson, + ValidJson, + parse_tool_call_arguments, +) def test_parse_valid_json_with_object() -> None: From 7a3334046f17d1b8997787950c3f14bfc703b9f3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ma=C5=82gorzata=20Zagajewska?= Date: Tue, 12 May 2026 22:17:10 +0200 Subject: [PATCH 39/51] Use nanoid for inference socket requestId --- package-lock.json | 26 +++++-------------- package.json | 2 +- paddler_client_javascript/package.json | 3 +++ .../src/inferenceSocketClient.ts | 3 ++- 4 files changed, 13 insertions(+), 21 deletions(-) diff --git a/package-lock.json b/package-lock.json index 3cb90abb..267ad31b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,7 +13,7 @@ "@codemirror/lang-jinja": "^6.0.0", "@uiw/react-codemirror": "^4.24.2", "clsx": "^2.1.1", - "nanoid": "^5.1.5", + "nanoid": "^5.1.11", "path-to-regexp": "^8.4.0", "react": "^19.1.1", "react-dom": "^19.1.1", @@ -225,7 +225,6 @@ "resolved": "https://registry.npmjs.org/@codemirror/view/-/view-6.38.1.tgz", "integrity": "sha512-RmTOkE7hRU3OVREqFVITWHz6ocgBjv08GoePscAakgVQfciA3SGCEk7mb9IzwW61cKKmlTpHXG6DUE5Ubx+MGQ==", "license": "MIT", - "peer": true, "dependencies": { "@codemirror/state": "^6.5.0", "crelt": "^1.0.6", @@ -249,7 +248,6 @@ } ], "license": "MIT", - "peer": true, "engines": { "node": ">=18" }, @@ -273,7 +271,6 @@ } ], "license": "MIT", - "peer": true, "engines": { "node": ">=18" } @@ -1316,7 +1313,6 @@ "resolved": "https://registry.npmjs.org/@types/react/-/react-19.1.10.tgz", "integrity": "sha512-EhBeSYX0Y6ye8pNebpKrwFJq7BoQ8J5SO6NlvNwwHjSj6adXJViPQrKlsyPw7hLBLvckEMO1yxeGdR82YBBlDg==", "license": "MIT", - "peer": true, "dependencies": { "csstype": "^3.0.2" } @@ -1383,7 +1379,6 @@ "integrity": "sha512-pUXGCuHnnKw6PyYq93lLRiZm3vjuslIy7tus1lIQTYVK9bL8XBgJnCWm8a0KcTtHC84Yya1Q6rtll+duSMj0dg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.39.1", "@typescript-eslint/types": "8.39.1", @@ -1785,7 +1780,6 @@ "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -2891,7 +2885,6 @@ "integrity": "sha512-TS9bTNIryDzStCpJN93aC5VRSW3uTx9sClUn4B87pwiCaJh220otoI0X8mJKr+VcPtniMdN8GKjlwgWGUv5ZKA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/regexpp": "^4.12.1", @@ -5050,9 +5043,9 @@ "license": "MIT" }, "node_modules/nanoid": { - "version": "5.1.5", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-5.1.5.tgz", - "integrity": "sha512-Ir/+ZpE9fDsNH0hQ3C68uyThDXzYcim2EqcZ8zn8Chtt1iylPT9xXJB0kPCnqzgcEGikO9RxSrh63MsmVCU7Fw==", + "version": "5.1.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-5.1.11.tgz", + "integrity": "sha512-v+KEsUv2ps74PaSKv0gHTxTCgMXOIfBEbaqa6w6ISIGC7ZsvHN4N9oJ8d4cmf0n5oTzQz2SLmThbQWhjd/8eKg==", "funding": [ { "type": "github", @@ -5451,7 +5444,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", @@ -5564,7 +5556,6 @@ "integrity": "sha512-8sLjZwK0R+JlxlYcTuVnyT2v+htpdrjDOKuMcOVdYjt52Lh8hWRYpxBPoKx/Zg+bcjc3wx6fmQevMmUztS/ccA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "cssesc": "^3.0.0", "util-deprecate": "^1.0.2" @@ -5615,7 +5606,6 @@ "integrity": "sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ==", "dev": true, "license": "MIT", - "peer": true, "bin": { "prettier": "bin/prettier.cjs" }, @@ -5705,7 +5695,6 @@ "resolved": "https://registry.npmjs.org/react/-/react-19.1.1.tgz", "integrity": "sha512-w8nqGImo45dmMIfljjMwOGtbmC/mk4CMYhWIicdSflH91J9TyCyczcPFXJzrZ/ZXcgGRFeP6BU0BEJTw6tZdfQ==", "license": "MIT", - "peer": true, "engines": { "node": ">=0.10.0" } @@ -5715,7 +5704,6 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.1.1.tgz", "integrity": "sha512-Dlq/5LAZgF0Gaz6yiqZCf6VCcZs1ghAJyrsu84Q/GT0gV+mCxbfmKNoGRKBYMJ8IEdGPqu49YWXD02GCknEDkw==", "license": "MIT", - "peer": true, "dependencies": { "scheduler": "^0.26.0" }, @@ -5909,7 +5897,6 @@ "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz", "integrity": "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==", "license": "Apache-2.0", - "peer": true, "dependencies": { "tslib": "^2.1.0" } @@ -7211,7 +7198,6 @@ "integrity": "sha512-CWBzXQrc/qOkhidw1OzBTQuYRbfyxDXJMVJ1XNwUHGROVmuaeiEm3OslpZ1RV96d7SKKjZKrSJu3+t/xlw3R9A==", "dev": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -7798,7 +7784,6 @@ "resolved": "https://registry.npmjs.org/zod/-/zod-4.0.17.tgz", "integrity": "sha512-1PHjlYRevNxxdy2JZ8JcNAw7rX8V9P1AKkP+x/xZfxB0K5FYfuV+Ug6P/6NVSR2jHQ+FzDDoDHS04nYUsOIyLQ==", "license": "MIT", - "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } @@ -7817,6 +7802,9 @@ "name": "@intentee/paddler-client", "version": "3.1.2", "license": "Apache-2.0", + "dependencies": { + "nanoid": "^5.1.11" + }, "devDependencies": { "@types/node": "^22", "ava": "^6.4.1", diff --git a/package.json b/package.json index 37905f24..ad583438 100644 --- a/package.json +++ b/package.json @@ -35,7 +35,7 @@ "@codemirror/lang-jinja": "^6.0.0", "@uiw/react-codemirror": "^4.24.2", "clsx": "^2.1.1", - "nanoid": "^5.1.5", + "nanoid": "^5.1.11", "path-to-regexp": "^8.4.0", "react": "^19.1.1", "react-dom": "^19.1.1", diff --git a/paddler_client_javascript/package.json b/paddler_client_javascript/package.json index 62d84110..5c63a42b 100644 --- a/paddler_client_javascript/package.json +++ b/paddler_client_javascript/package.json @@ -27,6 +27,9 @@ "rxjs": "^7.8", "zod": "^4" }, + "dependencies": { + "nanoid": "^5.1.11" + }, "devDependencies": { "@types/node": "^22", "ava": "^6.4.1", diff --git a/paddler_client_javascript/src/inferenceSocketClient.ts b/paddler_client_javascript/src/inferenceSocketClient.ts index a9700f97..82b95259 100644 --- a/paddler_client_javascript/src/inferenceSocketClient.ts +++ b/paddler_client_javascript/src/inferenceSocketClient.ts @@ -1,3 +1,4 @@ +import { nanoid } from "nanoid"; import { filter, fromEvent, map, takeWhile, type Observable } from "rxjs"; import { @@ -25,7 +26,7 @@ export function inferenceSocketClient({ enableThinking: boolean; messages: ConversationMessage[]; }): Observable { - const requestId = crypto.randomUUID(); + const requestId = nanoid(); const tokenStream = fromEvent(webSocket, "message").pipe( map(function (event): unknown { return event.data; From ee55691b6b0f9fd79dd662c801caabc258498b4a Mon Sep 17 00:00:00 2001 From: Mateusz Charytoniuk Date: Tue, 12 May 2026 22:40:02 +0200 Subject: [PATCH 40/51] update claude rules --- .claude/rules/code-style.md | 8 -------- .claude/rules/makefile.md | 13 +++++++++++++ .claude/rules/rust.md | 2 ++ .claude/rules/teamwork.md | 8 -------- .claude/rules/testing.md | 14 ++++++-------- 5 files changed, 21 insertions(+), 24 deletions(-) create mode 100644 .claude/rules/makefile.md diff --git a/.claude/rules/code-style.md b/.claude/rules/code-style.md index 2bdb831a..34f774a8 100644 --- a/.claude/rules/code-style.md +++ b/.claude/rules/code-style.md @@ -1,11 +1,3 @@ ---- -paths: - - "**/*.mjs" - - "**/*.py" - - "**/*.rs" - - "**/*.ts" ---- - # Coding Standards - Keep at most a single public struct per module. diff --git a/.claude/rules/makefile.md b/.claude/rules/makefile.md new file mode 100644 index 00000000..35407319 --- /dev/null +++ b/.claude/rules/makefile.md @@ -0,0 +1,13 @@ +--- +paths: + - "**/Makefile" +--- + +# Makefile Standards + +- Keep variables at the top of the file. Always. +- Prefer real targets over phony targets. If something can be express as a real target, do that. +- If you see that a phony target can be expressed as a real target, you can suggest a fix. +- Keep real targets, phony targets grouped together. Keep targets alphabetically sorted within each group. +- Keep all the real targets above phony targets. +- Make sure each Makefile target has enough dependencies to be able to run from a clean state. diff --git a/.claude/rules/rust.md b/.claude/rules/rust.md index be678983..052447c3 100644 --- a/.claude/rules/rust.md +++ b/.claude/rules/rust.md @@ -17,3 +17,5 @@ paths: - In Rust, when implementing a `new` method in a struct, prefer to use a struct with a parameter list instead of multiple function arguments. It should be easier to maintain. - Always check the project with Clippy. - Always format the code with `cargo fmt`. +- Each file must contain at most a single struct, or single enum. For readability split those into multiple modules. You can still keep multiple private function helpers. +- Never use Result<> as a function argument. diff --git a/.claude/rules/teamwork.md b/.claude/rules/teamwork.md index b5c14e43..34752f56 100644 --- a/.claude/rules/teamwork.md +++ b/.claude/rules/teamwork.md @@ -1,11 +1,3 @@ ---- -paths: - - "**/*.mjs" - - "**/*.py" - - "**/*.rs" - - "**/*.ts" ---- - # Teamwork and Project Organization Team members own one module each. The project needs to be organized around small self-contained modules. diff --git a/.claude/rules/testing.md b/.claude/rules/testing.md index 901b956f..c60bf4b2 100644 --- a/.claude/rules/testing.md +++ b/.claude/rules/testing.md @@ -1,11 +1,3 @@ ---- -paths: - - "**/*.mjs" - - "**/*.py" - - "**/*.rs" - - "**/*.ts" ---- - # Unit Tests and Quality Control - Always check that the unit tests pass. @@ -14,3 +6,9 @@ paths: - If some piece of code can be handled by proper types, use types instead. Write tests as a last resort. - In unit tests, make sure there is always just a single correct way to do a specific thing. Never accept fuzzy inputs from end users. - When working on tests, if you notice that the tested code can be better, you can suggest changes. +- Maintain 100% test coverage across the codebase. No file, branch, or line may be excluded from coverage reports. +- Reach 100% coverage with the minimum number of tests. Each test must cover a unique code path, behavior, or edge case that no other test already covers. +- If two tests cover overlapping paths, remove the weaker one. Redundant tests waste maintenance effort without improving correctness signal. +- Tests must exercise actual functionality and observable behavior. Never write a test purely to hit lines for the sake of coverage. +- Design tests deliberately before writing them. Identify the feature or branch under test, then write the smallest test that verifies it. +- Coverage gaps signal missing tests, never permission to exclude files. Write the test instead of suppressing the gap. From dcffc52bb0828d5015c25c5451698ebbaf125b3e Mon Sep 17 00:00:00 2001 From: Mateusz Charytoniuk Date: Tue, 12 May 2026 23:00:43 +0200 Subject: [PATCH 41/51] add SKILL to run the tests --- .claude/skills/running-all-tests/SKILL.md | 56 +++++++++++++++++++++++ Cargo.lock | 8 ++-- 2 files changed, 60 insertions(+), 4 deletions(-) create mode 100644 .claude/skills/running-all-tests/SKILL.md diff --git a/.claude/skills/running-all-tests/SKILL.md b/.claude/skills/running-all-tests/SKILL.md new file mode 100644 index 00000000..c7c82d52 --- /dev/null +++ b/.claude/skills/running-all-tests/SKILL.md @@ -0,0 +1,56 @@ +--- +name: running-all-tests +description: Runs every test suite in the paddler workspace on the fastest available device. Use when the user asks to run the tests, run all the tests, run the full test suite, or check that everything still passes. +--- + +# Running all tests + +Run every test suite in the workspace, picking the fastest compiled device backend for the host. + +## Step 1: detect the device + +Run this once at the start and echo the chosen device: + +```bash +if [[ "$OSTYPE" == "darwin"* ]]; then + DEVICE=metal +elif command -v nvidia-smi >/dev/null 2>&1 && nvidia-smi >/dev/null 2>&1; then + DEVICE=cuda +else + DEVICE=cpu +fi +echo "Device: $DEVICE" +``` + +`$DEVICE` selects the Rust integration suite variant in Step 2. The other four suites don't take a device feature. + +## Step 2: run the five suites + +Copy this checklist and tick each item as the suite completes: + +``` +- [ ] Rust unit +- [ ] Rust integration +- [ ] JS client +- [ ] Python client +- [ ] paddler_gui +``` + +| # | Suite | Inner command | Working dir | +|---|------------------|------------------------------------------------------------------------------------------------------------------------|--------------------------| +| 1 | Rust unit | `make test.unit` | repo root | +| 2 | Rust integration | `make test.integration` (cpu) / `make test.integration.cuda` / `make test.integration.metal` — pick by `$DEVICE` | repo root | +| 3 | JS client | `make test.client.js` | repo root | +| 4 | Python client | `poetry run pytest`, `poetry run ruff`, `poetry run mypy` | `paddler_client_python/` | +| 5 | paddler_gui | `cargo test -p paddler_gui --features web_admin_panel` | `paddler_gui/` | + +Run them in this order. Cheap suites (1, 3, 4) surface bugs quickly; the heavy GPU-bound suites (2, 5) come last. + +## Step 3: rules during the run + +- **Serialize GPU suites.** When `$DEVICE` is `cuda` or `metal`, run test suites sequentially to avoid device contention. +- **Per-test 30 s budget.** Flag any individual test that exceeds 30 s wall-clock. That is a real bug — production or test — not flakiness. + +## Step 4: report + +After all suites finish, sum up the results in an actionable report. diff --git a/Cargo.lock b/Cargo.lock index 70e65ce6..850d184b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3757,7 +3757,7 @@ checksum = "11d3d7f243d5c5a8b9bb5d6dd2b1602c0cb0b9db1621bafc7ed66e35ff9fe092" [[package]] name = "llama-cpp-bindings" -version = "0.5.0" +version = "0.6.0" dependencies = [ "encoding_rs", "enumflags2", @@ -3774,7 +3774,7 @@ dependencies = [ [[package]] name = "llama-cpp-bindings-build" -version = "0.5.0" +version = "0.6.0" dependencies = [ "bindgen", "cc", @@ -3787,14 +3787,14 @@ dependencies = [ [[package]] name = "llama-cpp-bindings-sys" -version = "0.5.0" +version = "0.6.0" dependencies = [ "llama-cpp-bindings-build", ] [[package]] name = "llama-cpp-bindings-types" -version = "0.5.0" +version = "0.6.0" dependencies = [ "serde", "serde_json", From d8e0a43cc5cc7942869f957ea767307465ec464e Mon Sep 17 00:00:00 2001 From: Mateusz Charytoniuk Date: Tue, 12 May 2026 23:19:27 +0200 Subject: [PATCH 42/51] update claude rules --- .claude/rules/github-workflows.md | 11 +++++++++++ .claude/rules/rust.md | 2 ++ 2 files changed, 13 insertions(+) create mode 100644 .claude/rules/github-workflows.md diff --git a/.claude/rules/github-workflows.md b/.claude/rules/github-workflows.md new file mode 100644 index 00000000..68d68c71 --- /dev/null +++ b/.claude/rules/github-workflows.md @@ -0,0 +1,11 @@ +--- +paths: + - ".github/**/*" +--- + +# GitHub Workflows Standards + +- Always use Makefile targets in the workflow to avoid code duplication. +- Never add the tests that use LLMs to GitHub workflows, because the default GitHub worker does not have the capacity to run them. +- Only add unit tests to GitHub workflows. +- Keep GitHub workflows responsible for only a single concern. For example, run linter, and tests in parallel. diff --git a/.claude/rules/rust.md b/.claude/rules/rust.md index 052447c3..3d940a23 100644 --- a/.claude/rules/rust.md +++ b/.claude/rules/rust.md @@ -19,3 +19,5 @@ paths: - Always format the code with `cargo fmt`. - Each file must contain at most a single struct, or single enum. For readability split those into multiple modules. You can still keep multiple private function helpers. - Never use Result<> as a function argument. +- Never forward Result in enums if you can instead create a targeted error enum. It is always better to signal the specific issue, so it can be handled downstream. +- Always destructure structs in arguments if possible. From 74f9001872084259bee238bac9ad6e6b7dd36af3 Mon Sep 17 00:00:00 2001 From: Mateusz Charytoniuk Date: Wed, 13 May 2026 01:25:46 +0200 Subject: [PATCH 43/51] switch ot llama.cpp from crates.io --- .claude/skills/running-all-tests/SKILL.md | 18 +- .gitignore | 1 - Cargo.lock | 8 + Cargo.toml | 6 +- package-lock.json | 2190 +++++------------ package.json | 10 - paddler_client_javascript/.gitignore | 1 - paddler_client_javascript/package.json | 12 +- .../tests/PaddlerError.test.ts | 45 +- .../tests/extractHuggingFaceUrlParts.test.ts | 19 +- .../tests/fetchJson.test.ts | 13 +- .../tests/schemas/Agent.test.ts | 13 +- ...renceServiceGenerateTokensResponse.test.ts | 76 +- .../tests/schemas/ParsedToolCall.test.ts | 19 +- .../tests/streamHttpNdjson.test.ts | 13 +- .../tests/urlToAgentDesiredModel.test.ts | 15 +- .../tests/webSocketProtocol.test.ts | 15 +- paddler_client_javascript/tsconfig.test.json | 8 + 18 files changed, 767 insertions(+), 1715 deletions(-) create mode 100644 paddler_client_javascript/tsconfig.test.json diff --git a/.claude/skills/running-all-tests/SKILL.md b/.claude/skills/running-all-tests/SKILL.md index c7c82d52..7af693e7 100644 --- a/.claude/skills/running-all-tests/SKILL.md +++ b/.claude/skills/running-all-tests/SKILL.md @@ -29,20 +29,20 @@ echo "Device: $DEVICE" Copy this checklist and tick each item as the suite completes: ``` -- [ ] Rust unit -- [ ] Rust integration - [ ] JS client - [ ] Python client +- [ ] Rust unit +- [ ] Rust integration - [ ] paddler_gui ``` -| # | Suite | Inner command | Working dir | -|---|------------------|------------------------------------------------------------------------------------------------------------------------|--------------------------| -| 1 | Rust unit | `make test.unit` | repo root | -| 2 | Rust integration | `make test.integration` (cpu) / `make test.integration.cuda` / `make test.integration.metal` — pick by `$DEVICE` | repo root | -| 3 | JS client | `make test.client.js` | repo root | -| 4 | Python client | `poetry run pytest`, `poetry run ruff`, `poetry run mypy` | `paddler_client_python/` | -| 5 | paddler_gui | `cargo test -p paddler_gui --features web_admin_panel` | `paddler_gui/` | +| # | Suite | Inner command | Working dir | +|---|------------------|-----------------------------------------------------------------------------------------------------------------------------------|--------------------------| +| 1 | JS client | `make test.client.js` | repo root | +| 2 | Python client | NixOS: `poetry run pytest`, `ruff`, `poetry run mypy"`. Every other OS: `poetry run pytest`, `poetry run ruff`, `poetry run mypy` | `paddler_client_python/` | +| 3 | Rust unit | `make test.unit` | repo root | +| 4 | Rust integration | `make test.integration` (cpu) / `make test.integration.cuda` / `make test.integration.metal` — pick by `$DEVICE` | repo root | +| 5 | paddler_gui | `cargo test -p paddler_gui --features web_admin_panel` | `paddler_gui/` | Run them in this order. Cheap suites (1, 3, 4) surface bugs quickly; the heavy GPU-bound suites (2, 5) come last. diff --git a/.gitignore b/.gitignore index 69980977..cb516b8c 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,5 @@ .DS_Store /*.db -/.tsimp /esbuild-meta.json /godepgraph.png /models diff --git a/Cargo.lock b/Cargo.lock index 850d184b..62046878 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3758,6 +3758,8 @@ checksum = "11d3d7f243d5c5a8b9bb5d6dd2b1602c0cb0b9db1621bafc7ed66e35ff9fe092" [[package]] name = "llama-cpp-bindings" version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c12d8de3511f1c3e3025e811ad2644d22d5b6657f18e9496ea3977c456eed8a" dependencies = [ "encoding_rs", "enumflags2", @@ -3775,6 +3777,8 @@ dependencies = [ [[package]] name = "llama-cpp-bindings-build" version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "352fc011d0d723af3864d500d3a78b2d5ee0a5200993229f93421984d9abbfe3" dependencies = [ "bindgen", "cc", @@ -3788,6 +3792,8 @@ dependencies = [ [[package]] name = "llama-cpp-bindings-sys" version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac6ce8ade04ae8cacd4ce4e627786c0a449c54d87b5e7287e936ab13fd8ceca8" dependencies = [ "llama-cpp-bindings-build", ] @@ -3795,6 +3801,8 @@ dependencies = [ [[package]] name = "llama-cpp-bindings-types" version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a76906d544513079d6dbd299d9f0469f618077153aba8eeaf950727a06b45aac" dependencies = [ "serde", "serde_json", diff --git a/Cargo.toml b/Cargo.toml index 13ff1a0a..17e67296 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -36,9 +36,9 @@ hf-hub = { version = "0.4", features = ["tokio"] } image = "0.25" indoc = "2" jsonschema = { version = "0.37", default-features = false } -llama-cpp-bindings = { path = "../llama-cpp-bindings/llama-cpp-bindings" } -llama-cpp-bindings-sys = { path = "../llama-cpp-bindings/llama-cpp-bindings-sys" } -llama-cpp-bindings-types = { path = "../llama-cpp-bindings/llama-cpp-bindings-types" } +llama-cpp-bindings = "=0.6.0" +llama-cpp-bindings-sys = "=0.6.0" +llama-cpp-bindings-types = "=0.6.0" base64 = "0.22" log = "0.4" mime_guess = "2" diff --git a/package-lock.json b/package-lock.json index 267ad31b..69b7026c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -26,7 +26,6 @@ "@types/hotwired__turbo": "^8", "@types/react": "^19.1.10", "@types/react-dom": "^19.1.7", - "ava": "^6.4.1", "esbuild": "^0.25.8", "eslint": "^9.33.0", "eslint-plugin-react-hooks": "^5.2.0", @@ -40,7 +39,6 @@ "stylelint": "^16.23.1", "stylelint-config-recommended": "^17.0.0", "tempy": "^3.1.0", - "tsimp": "^2.0.12", "tslib": "^2.8.1", "typed-css-modules": "^0.9.1", "typescript": "^5.9.2", @@ -986,23 +984,6 @@ "resolved": "paddler_client_javascript", "link": true }, - "node_modules/@isaacs/cached": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@isaacs/cached/-/cached-1.0.1.tgz", - "integrity": "sha512-7kGcJ9Hc1f4qpTApWz3swxbF9Qv1NF/GxuPtXeTptbsgvJIoufSd0h854Nq/2bw80F5C1onsFgEI05l+q0e4vw==", - "dev": true, - "license": "BlueOak-1.0.0", - "dependencies": { - "@isaacs/catcher": "^1.0.0" - } - }, - "node_modules/@isaacs/catcher": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/@isaacs/catcher/-/catcher-1.0.4.tgz", - "integrity": "sha512-g2klMwbnguClWNnCeQ1zYaDJsvPbIbnjdJPDE0z09MqoejJDZSLK5vIKiClq2Bkg5ubuI8vaN6wfIUi5GYzMVA==", - "dev": true, - "license": "BlueOak-1.0.0" - }, "node_modules/@isaacs/cliui": { "version": "8.0.2", "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", @@ -1046,19 +1027,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/@isaacs/fs-minipass": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/@isaacs/fs-minipass/-/fs-minipass-4.0.1.tgz", - "integrity": "sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w==", - "dev": true, - "license": "ISC", - "dependencies": { - "minipass": "^7.0.4" - }, - "engines": { - "node": ">=18.0.0" - } - }, "node_modules/@keyv/serialize": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@keyv/serialize/-/serialize-1.1.0.tgz", @@ -1123,28 +1091,6 @@ "@lezer/common": "^1.0.0" } }, - "node_modules/@mapbox/node-pre-gyp": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/@mapbox/node-pre-gyp/-/node-pre-gyp-2.0.0.tgz", - "integrity": "sha512-llMXd39jtP0HpQLVI37Bf1m2ADlEb35GYSh1SDSLsBhR+5iCxiNGlT31yqbNtVHygHAtMy6dWFERpU2JgufhPg==", - "dev": true, - "license": "BSD-3-Clause", - "dependencies": { - "consola": "^3.2.3", - "detect-libc": "^2.0.0", - "https-proxy-agent": "^7.0.5", - "node-fetch": "^2.6.7", - "nopt": "^8.0.0", - "semver": "^7.5.3", - "tar": "^7.4.0" - }, - "bin": { - "node-pre-gyp": "bin/node-pre-gyp" - }, - "engines": { - "node": ">=18" - } - }, "node_modules/@marijn/find-cluster-break": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/@marijn/find-cluster-break/-/find-cluster-break-1.0.2.tgz", @@ -1200,42 +1146,6 @@ "node": ">=14" } }, - "node_modules/@rollup/pluginutils": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/@rollup/pluginutils/-/pluginutils-5.2.0.tgz", - "integrity": "sha512-qWJ2ZTbmumwiLFomfzTyt5Kng4hwPi9rwCYN4SHb6eaRU1KNO4ccxINHr/VhH4GgPlt1XfSTLX2LBTme8ne4Zw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/estree": "^1.0.0", - "estree-walker": "^2.0.2", - "picomatch": "^4.0.2" - }, - "engines": { - "node": ">=14.0.0" - }, - "peerDependencies": { - "rollup": "^1.20.0||^2.0.0||^3.0.0||^4.0.0" - }, - "peerDependenciesMeta": { - "rollup": { - "optional": true - } - } - }, - "node_modules/@sindresorhus/merge-streams": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/@sindresorhus/merge-streams/-/merge-streams-2.3.0.tgz", - "integrity": "sha512-LtoMMhxAlorcGhmFYI+LhPgbPZCkgP6ra1YL604EeF6U98pLlQ3iWIGMdWSC+vWmPBWBNgmDBAhnAobLROJmwg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/@types/debug": { "version": "4.1.12", "resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.12.tgz", @@ -1650,130 +1560,6 @@ "integrity": "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==", "license": "ISC" }, - "node_modules/@vercel/nft": { - "version": "0.29.4", - "resolved": "https://registry.npmjs.org/@vercel/nft/-/nft-0.29.4.tgz", - "integrity": "sha512-6lLqMNX3TuycBPABycx7A9F1bHQR7kiQln6abjFbPrf5C/05qHM9M5E4PeTE59c7z8g6vHnx1Ioihb2AQl7BTA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@mapbox/node-pre-gyp": "^2.0.0", - "@rollup/pluginutils": "^5.1.3", - "acorn": "^8.6.0", - "acorn-import-attributes": "^1.9.5", - "async-sema": "^3.1.1", - "bindings": "^1.4.0", - "estree-walker": "2.0.2", - "glob": "^10.4.5", - "graceful-fs": "^4.2.9", - "node-gyp-build": "^4.2.2", - "picomatch": "^4.0.2", - "resolve-from": "^5.0.0" - }, - "bin": { - "nft": "out/cli.js" - }, - "engines": { - "node": ">=18" - } - }, - "node_modules/@vercel/nft/node_modules/brace-expansion": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.0.tgz", - "integrity": "sha512-TN1kCZAgdgweJhWWpgKYrQaMNHcDULHkWwQIspdtjV4Y5aurRdZpjAqn6yX3FPqTA9ngHCc4hJxMAMgGfve85w==", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^1.0.0" - } - }, - "node_modules/@vercel/nft/node_modules/glob": { - "version": "10.5.0", - "resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz", - "integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==", - "dev": true, - "license": "ISC", - "dependencies": { - "foreground-child": "^3.1.0", - "jackspeak": "^3.1.2", - "minimatch": "^9.0.4", - "minipass": "^7.1.2", - "package-json-from-dist": "^1.0.0", - "path-scurry": "^1.11.1" - }, - "bin": { - "glob": "dist/esm/bin.mjs" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/@vercel/nft/node_modules/jackspeak": { - "version": "3.4.3", - "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz", - "integrity": "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==", - "dev": true, - "license": "BlueOak-1.0.0", - "dependencies": { - "@isaacs/cliui": "^8.0.2" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - }, - "optionalDependencies": { - "@pkgjs/parseargs": "^0.11.0" - } - }, - "node_modules/@vercel/nft/node_modules/lru-cache": { - "version": "10.4.3", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", - "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", - "dev": true, - "license": "ISC" - }, - "node_modules/@vercel/nft/node_modules/minimatch": { - "version": "9.0.9", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", - "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", - "dev": true, - "license": "ISC", - "dependencies": { - "brace-expansion": "^2.0.2" - }, - "engines": { - "node": ">=16 || 14 >=14.17" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/@vercel/nft/node_modules/path-scurry": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz", - "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==", - "dev": true, - "license": "BlueOak-1.0.0", - "dependencies": { - "lru-cache": "^10.2.0", - "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" - }, - "engines": { - "node": ">=16 || 14 >=14.18" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/abbrev": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-3.0.1.tgz", - "integrity": "sha512-AO2ac6pjRB3SJmGJo+v5/aK6Omggp6fsLrs6wN9bd35ulu4cCwaAU9+7ZhXjeqHVkaHThLuzH0nZr0YpCDhygg==", - "dev": true, - "license": "ISC", - "engines": { - "node": "^18.17.0 || >=20.5.0" - } - }, "node_modules/acorn": { "version": "8.15.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", @@ -1787,16 +1573,6 @@ "node": ">=0.4.0" } }, - "node_modules/acorn-import-attributes": { - "version": "1.9.5", - "resolved": "https://registry.npmjs.org/acorn-import-attributes/-/acorn-import-attributes-1.9.5.tgz", - "integrity": "sha512-n02Vykv5uA3eHGM/Z2dQrcD56kL8TyDb2p1+0P83PClMnC/nc+anbQRhIOWnSq4Ke/KvDPrY3C9hDtC/A3eHnQ==", - "dev": true, - "license": "MIT", - "peerDependencies": { - "acorn": "^8" - } - }, "node_modules/acorn-jsx": { "version": "5.3.2", "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", @@ -1807,29 +1583,6 @@ "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, - "node_modules/acorn-walk": { - "version": "8.3.4", - "resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.3.4.tgz", - "integrity": "sha512-ueEepnujpqee2o5aIYnvHU6C0A42MNdsIDeqy5BydrkuC5R1ZuUFnm27EeFJGoEHJQgn3uleRvmTXaJgfXbt4g==", - "dev": true, - "license": "MIT", - "dependencies": { - "acorn": "^8.11.0" - }, - "engines": { - "node": ">=0.4.0" - } - }, - "node_modules/agent-base": { - "version": "7.1.4", - "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", - "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 14" - } - }, "node_modules/ajv": { "version": "6.14.0", "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.14.0.tgz", @@ -1907,16 +1660,6 @@ "dev": true, "license": "Python-2.0" }, - "node_modules/array-find-index": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/array-find-index/-/array-find-index-1.0.2.tgz", - "integrity": "sha512-M1HQyIXcBGtVywBt8WVdim+lrNaK7VHp99Qt5pSNziXznKHViIBbXWtfRTpEFpF/c4FdfxNAsCCwPp5phBYJtw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/array-union": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/array-union/-/array-union-2.1.0.tgz", @@ -1927,29 +1670,6 @@ "node": ">=8" } }, - "node_modules/arrgv": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/arrgv/-/arrgv-1.0.2.tgz", - "integrity": "sha512-a4eg4yhp7mmruZDQFqVMlxNRFGi/i1r87pt8SDHy0/I8PqSXoUTlWZRdAZo0VXgvEARcujbtTk8kiZRi1uDGRw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8.0.0" - } - }, - "node_modules/arrify": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/arrify/-/arrify-3.0.0.tgz", - "integrity": "sha512-tLkvA81vQG/XqE2mjDkGQHoOINtMHtysSnemrmoGe6PydDPMRbVugqyk4A6V/WDWEfm3l+0d8anA9r8cv/5Jaw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/astral-regex": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/astral-regex/-/astral-regex-2.0.0.tgz", @@ -1960,76 +1680,6 @@ "node": ">=8" } }, - "node_modules/async-sema": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/async-sema/-/async-sema-3.1.1.tgz", - "integrity": "sha512-tLRNUXati5MFePdAk8dw7Qt7DpxPB60ofAgn8WRhW6a2rcimZnYBP9oxHiv0OHy+Wz7kPMG+t4LGdt31+4EmGg==", - "dev": true, - "license": "MIT" - }, - "node_modules/ava": { - "version": "6.4.1", - "resolved": "https://registry.npmjs.org/ava/-/ava-6.4.1.tgz", - "integrity": "sha512-vxmPbi1gZx9zhAjHBgw81w/iEDKcrokeRk/fqDTyA2DQygZ0o+dUGRHFOtX8RA5N0heGJTTsIk7+xYxitDb61Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "@vercel/nft": "^0.29.4", - "acorn": "^8.15.0", - "acorn-walk": "^8.3.4", - "ansi-styles": "^6.2.1", - "arrgv": "^1.0.2", - "arrify": "^3.0.0", - "callsites": "^4.2.0", - "cbor": "^10.0.9", - "chalk": "^5.4.1", - "chunkd": "^2.0.1", - "ci-info": "^4.3.0", - "ci-parallel-vars": "^1.0.1", - "cli-truncate": "^4.0.0", - "code-excerpt": "^4.0.0", - "common-path-prefix": "^3.0.0", - "concordance": "^5.0.4", - "currently-unhandled": "^0.4.1", - "debug": "^4.4.1", - "emittery": "^1.2.0", - "figures": "^6.1.0", - "globby": "^14.1.0", - "ignore-by-default": "^2.1.0", - "indent-string": "^5.0.0", - "is-plain-object": "^5.0.0", - "is-promise": "^4.0.0", - "matcher": "^5.0.0", - "memoize": "^10.1.0", - "ms": "^2.1.3", - "p-map": "^7.0.3", - "package-config": "^5.0.0", - "picomatch": "^4.0.2", - "plur": "^5.1.0", - "pretty-ms": "^9.2.0", - "resolve-cwd": "^3.0.0", - "stack-utils": "^2.0.6", - "strip-ansi": "^7.1.0", - "supertap": "^3.0.1", - "temp-dir": "^3.0.0", - "write-file-atomic": "^6.0.0", - "yargs": "^17.7.2" - }, - "bin": { - "ava": "entrypoints/cli.mjs" - }, - "engines": { - "node": "^18.18 || ^20.8 || ^22 || ^23 || >=24" - }, - "peerDependencies": { - "@ava/typescript": "*" - }, - "peerDependenciesMeta": { - "@ava/typescript": { - "optional": true - } - } - }, "node_modules/bail": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/bail/-/bail-2.0.2.tgz", @@ -2060,23 +1710,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/bindings": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz", - "integrity": "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "file-uri-to-path": "1.0.0" - } - }, - "node_modules/blueimp-md5": { - "version": "2.19.0", - "resolved": "https://registry.npmjs.org/blueimp-md5/-/blueimp-md5-2.19.0.tgz", - "integrity": "sha512-DRQrD6gJyy8FbiE4s+bDoXS9hiW3Vbx5uCdwvcCf3zLHL+Iv7LtGHLpr+GZV8rHG8tK766FGYBwRbu8pELTt+w==", - "dev": true, - "license": "MIT" - }, "node_modules/brace-expansion": { "version": "1.1.14", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.14.tgz", @@ -2122,19 +1755,6 @@ "@keyv/serialize": "^1.1.0" } }, - "node_modules/callsites": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/callsites/-/callsites-4.2.0.tgz", - "integrity": "sha512-kfzR4zzQtAE9PC7CzZsjl3aBNbXWuXiSeOCdLcPpBfGW8YuCqQHcRPFDbr/BPVmd3EEPVpuFzLyuT/cUhPr4OQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12.20" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/camelcase": { "version": "6.3.0", "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-6.3.0.tgz", @@ -2148,19 +1768,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/cbor": { - "version": "10.0.9", - "resolved": "https://registry.npmjs.org/cbor/-/cbor-10.0.9.tgz", - "integrity": "sha512-KEWYehb/vJkRmigctVQLsz73Us2RNnITo/wOwQV5AtZpLGH1r2PPlsNHdsX460YuHZCyhLklbYzAOuJfOeg34Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "nofilter": "^3.0.2" - }, - "engines": { - "node": ">=20" - } - }, "node_modules/ccount": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/ccount/-/ccount-2.0.1.tgz", @@ -2171,19 +1778,6 @@ "url": "https://github.com/sponsors/wooorm" } }, - "node_modules/chalk": { - "version": "5.4.1", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.4.1.tgz", - "integrity": "sha512-zgVZuo2WcZgfUEmsn6eO3kINexW8RAE4maiQ8QNs8CtpPCSyMiYsULR3HQYkm3w8FIA3SberyMJMSldGsW+U3w==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^12.17.0 || ^14.13 || >=16.0.0" - }, - "funding": { - "url": "https://github.com/chalk/chalk?sponsor=1" - } - }, "node_modules/character-entities": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/character-entities/-/character-entities-2.0.2.tgz", @@ -2240,67 +1834,10 @@ "url": "https://paulmillr.com/funding/" } }, - "node_modules/chownr": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/chownr/-/chownr-3.0.0.tgz", - "integrity": "sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g==", - "dev": true, - "license": "BlueOak-1.0.0", - "engines": { - "node": ">=18" - } - }, - "node_modules/chunkd": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/chunkd/-/chunkd-2.0.1.tgz", - "integrity": "sha512-7d58XsFmOq0j6el67Ug9mHf9ELUXsQXYJBkyxhH/k+6Ke0qXRnv0kbemx+Twc6fRJ07C49lcbdgm9FL1Ei/6SQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/ci-info": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-4.3.0.tgz", - "integrity": "sha512-l+2bNRMiQgcfILUi33labAZYIWlH1kWDp+ecNo5iisRKrbm0xcRyCww71/YU0Fkw0mAFpz9bJayXPjey6vkmaQ==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/sibiraj-s" - } - ], - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/ci-parallel-vars": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/ci-parallel-vars/-/ci-parallel-vars-1.0.1.tgz", - "integrity": "sha512-uvzpYrpmidaoxvIQHM+rKSrigjOe9feHYbw4uOI2gdfe1C3xIlxO+kVXq83WQWNniTf8bAxVpy+cQeFQsMERKg==", - "dev": true, - "license": "MIT" - }, - "node_modules/cli-truncate": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/cli-truncate/-/cli-truncate-4.0.0.tgz", - "integrity": "sha512-nPdaFdQ0h/GEigbPClz11D0v/ZJEwxmeVZGeMo3Z5StPtUTkA9o1lD6QwoirYiSDzbcwn2XcjwmCp68W1IS4TA==", - "dev": true, - "license": "MIT", - "dependencies": { - "slice-ansi": "^5.0.0", - "string-width": "^7.0.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/cliui": { - "version": "8.0.1", - "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", - "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", + "node_modules/cliui": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", + "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", "dev": true, "license": "ISC", "dependencies": { @@ -2410,19 +1947,6 @@ "node": ">=6" } }, - "node_modules/code-excerpt": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/code-excerpt/-/code-excerpt-4.0.0.tgz", - "integrity": "sha512-xxodCmBen3iy2i0WtAK8FlFNrRzjUqjRsMfho58xT/wvZU1YTM3fCnRjcy1gJPMepaRlgm/0e6w8SpWHpn3/cA==", - "dev": true, - "license": "MIT", - "dependencies": { - "convert-to-spaces": "^2.0.1" - }, - "engines": { - "node": "^12.20.0 || ^14.13.1 || >=16.0.0" - } - }, "node_modules/codemirror": { "version": "6.0.2", "resolved": "https://registry.npmjs.org/codemirror/-/codemirror-6.0.2.tgz", @@ -2475,13 +1999,6 @@ "url": "https://github.com/sponsors/wooorm" } }, - "node_modules/common-path-prefix": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/common-path-prefix/-/common-path-prefix-3.0.0.tgz", - "integrity": "sha512-QE33hToZseCH3jS0qN96O/bSh3kaw/h+Tq7ngyY9eWDUnTlTNUyqfqvCXioLe5Na5jFsL78ra/wuBU4iuEgd4w==", - "dev": true, - "license": "ISC" - }, "node_modules/concat-map": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", @@ -2489,46 +2006,6 @@ "dev": true, "license": "MIT" }, - "node_modules/concordance": { - "version": "5.0.4", - "resolved": "https://registry.npmjs.org/concordance/-/concordance-5.0.4.tgz", - "integrity": "sha512-OAcsnTEYu1ARJqWVGwf4zh4JDfHZEaSNlNccFmt8YjB2l/n19/PF2viLINHc57vO4FKIAFl2FWASIGZZWZ2Kxw==", - "dev": true, - "license": "ISC", - "dependencies": { - "date-time": "^3.1.0", - "esutils": "^2.0.3", - "fast-diff": "^1.2.0", - "js-string-escape": "^1.0.1", - "lodash": "^4.17.15", - "md5-hex": "^3.0.1", - "semver": "^7.3.2", - "well-known-symbols": "^2.0.0" - }, - "engines": { - "node": ">=10.18.0 <11 || >=12.14.0 <13 || >=14" - } - }, - "node_modules/consola": { - "version": "3.4.2", - "resolved": "https://registry.npmjs.org/consola/-/consola-3.4.2.tgz", - "integrity": "sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^14.18.0 || >=16.10.0" - } - }, - "node_modules/convert-to-spaces": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/convert-to-spaces/-/convert-to-spaces-2.0.1.tgz", - "integrity": "sha512-rcQ1bsQO9799wq24uE5AM2tAILy4gXGIK/njFWcVQkGNZ96edlpY+A7bjwvzjYvLDyzmG1MmMLZhpcsb+klNMQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^12.20.0 || ^14.13.1 || >=16.0.0" - } - }, "node_modules/cosmiconfig": { "version": "9.0.0", "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-9.0.0.tgz", @@ -2649,32 +2126,6 @@ "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==", "license": "MIT" }, - "node_modules/currently-unhandled": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/currently-unhandled/-/currently-unhandled-0.4.1.tgz", - "integrity": "sha512-/fITjgjGU50vjQ4FH6eUoYu+iUoUKIXws2hL15JJpIR+BbTxaXQsMuuyjtNh2WqsSBS5nsaZHFsFecyw5CCAng==", - "dev": true, - "license": "MIT", - "dependencies": { - "array-find-index": "^1.0.1" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/date-time": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/date-time/-/date-time-3.1.0.tgz", - "integrity": "sha512-uqCUKXE5q1PNBXjPqvwhwJf9SwMoAHBgWJ6DcrnS5o+W2JOiIILl0JEdVD8SGujrNS02GGxgwAg2PN2zONgtjg==", - "dev": true, - "license": "MIT", - "dependencies": { - "time-zone": "^1.0.0" - }, - "engines": { - "node": ">=6" - } - }, "node_modules/debug": { "version": "4.4.1", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.1.tgz", @@ -2721,16 +2172,6 @@ "node": ">=6" } }, - "node_modules/detect-libc": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.0.4.tgz", - "integrity": "sha512-3UDv+G9CsCKO1WKMGw9fwq/SWJYbI0c5Y7LU1AXYoDdbhE2AHQ6N6Nb34sG8Fj7T5APy8qXDCKuuIHd1BR0tVA==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": ">=8" - } - }, "node_modules/devlop": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/devlop/-/devlop-1.1.0.tgz", @@ -2774,26 +2215,6 @@ "dev": true, "license": "MIT" }, - "node_modules/emittery": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/emittery/-/emittery-1.2.0.tgz", - "integrity": "sha512-KxdRyyFcS85pH3dnU8Y5yFUm2YJdaHwcBZWrfG8o89ZY9a13/f9itbN+YG3ELbBo9Pg5zvIozstmuV8bX13q6g==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=14.16" - }, - "funding": { - "url": "https://github.com/sindresorhus/emittery?sponsor=1" - } - }, - "node_modules/emoji-regex": { - "version": "10.4.0", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.4.0.tgz", - "integrity": "sha512-EC+0oUMY1Rqm4O6LLrgjtYDvcVYTy7chDnM4Q7030tP4Kwj3u/pR6gP9ygnp2CJMK5Gq+9Q2oqmrFJAz01DXjw==", - "dev": true, - "license": "MIT" - }, "node_modules/env-paths": { "version": "2.2.1", "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-2.2.1.tgz", @@ -3034,20 +2455,6 @@ "url": "https://opencollective.com/eslint" } }, - "node_modules/esprima": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz", - "integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==", - "dev": true, - "license": "BSD-2-Clause", - "bin": { - "esparse": "bin/esparse.js", - "esvalidate": "bin/esvalidate.js" - }, - "engines": { - "node": ">=4" - } - }, "node_modules/esquery": { "version": "1.6.0", "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.6.0.tgz", @@ -3094,13 +2501,6 @@ "url": "https://opencollective.com/unified" } }, - "node_modules/estree-walker": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz", - "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==", - "dev": true, - "license": "MIT" - }, "node_modules/esutils": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", @@ -3124,13 +2524,6 @@ "dev": true, "license": "MIT" }, - "node_modules/fast-diff": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/fast-diff/-/fast-diff-1.3.0.tgz", - "integrity": "sha512-VxPP4NqbUjj6MaAOafWeUn2cXWLcCtljklUtZf0Ind4XQ+QPtmA0b18zZy0jIQx+ExRVCR/ZQpBmik5lXshNsw==", - "dev": true, - "license": "Apache-2.0" - }, "node_modules/fast-glob": { "version": "3.3.3", "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", @@ -3212,22 +2605,6 @@ "reusify": "^1.0.4" } }, - "node_modules/figures": { - "version": "6.1.0", - "resolved": "https://registry.npmjs.org/figures/-/figures-6.1.0.tgz", - "integrity": "sha512-d+l3qxjSesT4V7v2fh+QnmFnUWv9lSpjarhShNTgBOfA0ttejbQUAlHLitbjkoRiDulW0OPoQPYIGhIC8ohejg==", - "dev": true, - "license": "MIT", - "dependencies": { - "is-unicode-supported": "^2.0.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/file-entry-cache": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", @@ -3241,13 +2618,6 @@ "node": ">=16.0.0" } }, - "node_modules/file-uri-to-path": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz", - "integrity": "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==", - "dev": true, - "license": "MIT" - }, "node_modules/fill-range": { "version": "7.1.1", "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", @@ -3278,19 +2648,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/find-up-simple": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/find-up-simple/-/find-up-simple-1.0.1.tgz", - "integrity": "sha512-afd4O7zpqHeRyg4PfDQsXmlDe2PfdHtJt6Akt8jOWaApLOZk5JXs6VMR29lz03pRe9mpykrRCYIYxaJYcfpncQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/flat-cache": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz", @@ -3369,17 +2726,17 @@ "node": "6.* || 8.* || >= 10.*" } }, - "node_modules/get-east-asian-width": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.3.0.tgz", - "integrity": "sha512-vpeMIQKxczTD/0s2CdEWHcb0eeJe6TFjxb+J5xgX7hScxqrGuyjmv4c1D4A/gelKfyox0gJJwIHF+fLjeaM8kQ==", + "node_modules/get-tsconfig": { + "version": "4.14.0", + "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.14.0.tgz", + "integrity": "sha512-yTb+8DXzDREzgvYmh6s9vHsSVCHeC0G3PI5bEXNBHtmshPnO+S5O7qgLEOn0I5QvMy6kpZN8K1NKGyilLb93wA==", "dev": true, "license": "MIT", - "engines": { - "node": ">=18" + "dependencies": { + "resolve-pkg-maps": "^1.0.0" }, "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" } }, "node_modules/glob": { @@ -3512,37 +2869,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/globby": { - "version": "14.1.0", - "resolved": "https://registry.npmjs.org/globby/-/globby-14.1.0.tgz", - "integrity": "sha512-0Ia46fDOaT7k4og1PDW4YbodWWr3scS2vAr2lTbsplOt2WkKp0vQbkI9wKis/T5LV/dqPjO3bpS/z6GTJB82LA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@sindresorhus/merge-streams": "^2.1.0", - "fast-glob": "^3.3.3", - "ignore": "^7.0.3", - "path-type": "^6.0.0", - "slash": "^5.1.0", - "unicorn-magic": "^0.3.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/globby/node_modules/ignore": { - "version": "7.0.5", - "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz", - "integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 4" - } - }, "node_modules/globjoin": { "version": "0.1.4", "resolved": "https://registry.npmjs.org/globjoin/-/globjoin-0.1.4.tgz", @@ -3651,20 +2977,6 @@ "url": "https://opencollective.com/unified" } }, - "node_modules/https-proxy-agent": { - "version": "7.0.6", - "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", - "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", - "dev": true, - "license": "MIT", - "dependencies": { - "agent-base": "^7.1.2", - "debug": "4" - }, - "engines": { - "node": ">= 14" - } - }, "node_modules/icss-replace-symbols": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/icss-replace-symbols/-/icss-replace-symbols-1.1.0.tgz", @@ -3695,16 +3007,6 @@ "node": ">= 4" } }, - "node_modules/ignore-by-default": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/ignore-by-default/-/ignore-by-default-2.1.0.tgz", - "integrity": "sha512-yiWd4GVmJp0Q6ghmM2B/V3oZGRmjrKLXvHR3TE1nfoXsmoggllfZUQe74EN0fJdPFZu2NIvNdrMMLm3OsV7Ohw==", - "dev": true, - "license": "ISC", - "engines": { - "node": ">=10 <11 || >=12 <13 || >=14" - } - }, "node_modules/import-fresh": { "version": "3.3.1", "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", @@ -3742,19 +3044,6 @@ "node": ">=0.8.19" } }, - "node_modules/indent-string": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-5.0.0.tgz", - "integrity": "sha512-m6FAo/spmsW2Ab2fU35JTYwtOKa2yAwXSwgjSv1TJzh4Mh7mC3lzAOVLBprb72XsTrgkEIsl7YrFNAiDiRhIGg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/ini": { "version": "1.3.8", "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz", @@ -3768,16 +3057,6 @@ "integrity": "sha512-0aO8FkhNZlj/ZIbNi7Lxxr12obT7cL1moPfE4tg1LkX7LlLfC6DeX4l2ZEud1ukP9jNQyNnfzQVqwbwmAATY4Q==", "license": "MIT" }, - "node_modules/irregular-plurals": { - "version": "3.5.0", - "resolved": "https://registry.npmjs.org/irregular-plurals/-/irregular-plurals-3.5.0.tgz", - "integrity": "sha512-1ANGLZ+Nkv1ptFb2pa8oG8Lem4krflKuX/gINiHJHjJUKaJHk/SXk5x6K3J+39/p0h1RQ2saROclJJ+QLvETCQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, "node_modules/is-alphabetical": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/is-alphabetical/-/is-alphabetical-2.0.1.tgz", @@ -3858,19 +3137,6 @@ "node": ">=0.10.0" } }, - "node_modules/is-fullwidth-code-point": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-4.0.0.tgz", - "integrity": "sha512-O4L094N2/dZ7xqVdrXhh9r1KODPJpFms8B5sGdJLPy664AgvXsreZUyCQQNItZRDlYug4xStLjNp/sz3HvBowQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/is-glob": { "version": "4.0.3", "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", @@ -3926,13 +3192,6 @@ "node": ">=0.10.0" } }, - "node_modules/is-promise": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz", - "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==", - "dev": true, - "license": "MIT" - }, "node_modules/is-stream": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-3.0.0.tgz", @@ -3953,19 +3212,6 @@ "dev": true, "license": "MIT" }, - "node_modules/is-unicode-supported": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/is-unicode-supported/-/is-unicode-supported-2.1.0.tgz", - "integrity": "sha512-mE00Gnza5EEB3Ds0HfMyllZzbBrmLOX3vfWoj9A9PEnTfratQ/BcaJOuMhnkhjXvb2+FkY3VuHqtAGpTPmglFQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/is-wsl": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-2.2.0.tgz", @@ -4055,16 +3301,6 @@ "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/js-string-escape": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/js-string-escape/-/js-string-escape-1.0.1.tgz", - "integrity": "sha512-Smw4xcfIQ5LVjAOuJCvN/zIodzA/BBSsluuoSykP+lUvScIi4U6RJLfwHet5cxFnCswUjISV8oAXaqaJDY3chg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, "node_modules/js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", @@ -4174,19 +3410,6 @@ "dev": true, "license": "MIT" }, - "node_modules/load-json-file": { - "version": "7.0.1", - "resolved": "https://registry.npmjs.org/load-json-file/-/load-json-file-7.0.1.tgz", - "integrity": "sha512-Gnxj3ev3mB5TkVBGad0JM6dmLiQL+o0t23JPBZ9sd+yvSLk05mFoqKBw5N8gbbkU4TNXyqCgIrl/VM17OgUIgQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^12.20.0 || ^14.13.1 || >=16.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/locate-path": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", @@ -4244,39 +3467,10 @@ "node": "20 || >=22" } }, - "node_modules/matcher": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/matcher/-/matcher-5.0.0.tgz", - "integrity": "sha512-s2EMBOWtXFc8dgqvoAzKJXxNHibcdJMV0gwqKUaw9E2JBJuGUK7DrNKrA6g/i+v72TT16+6sVm5mS3thaMLQUw==", - "dev": true, - "license": "MIT", - "dependencies": { - "escape-string-regexp": "^5.0.0" - }, - "engines": { - "node": "^12.20.0 || ^14.13.1 || >=16.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/matcher/node_modules/escape-string-regexp": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-5.0.0.tgz", - "integrity": "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/mathml-tag-names": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/mathml-tag-names/-/mathml-tag-names-2.1.3.tgz", - "integrity": "sha512-APMBEanjybaPzUrfqU0IMU5I0AswKMH7k8OTLs0vvV4KZpExkTkY87nR/zpbuTPj+gARop7aGUbl11pnDfW6xg==", + "node_modules/mathml-tag-names": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/mathml-tag-names/-/mathml-tag-names-2.1.3.tgz", + "integrity": "sha512-APMBEanjybaPzUrfqU0IMU5I0AswKMH7k8OTLs0vvV4KZpExkTkY87nR/zpbuTPj+gARop7aGUbl11pnDfW6xg==", "dev": true, "license": "MIT", "funding": { @@ -4284,19 +3478,6 @@ "url": "https://github.com/sponsors/wooorm" } }, - "node_modules/md5-hex": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/md5-hex/-/md5-hex-3.0.1.tgz", - "integrity": "sha512-BUiRtTtV39LIJwinWBjqVsU9xhdnz7/i889V859IBFpuqGAj6LuOvHv5XLbgZ2R7ptJoJaEcxkv88/h25T7Ciw==", - "dev": true, - "license": "MIT", - "dependencies": { - "blueimp-md5": "^2.10.0" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/mdast-util-from-markdown": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/mdast-util-from-markdown/-/mdast-util-from-markdown-2.0.2.tgz", @@ -4457,22 +3638,6 @@ "dev": true, "license": "CC0-1.0" }, - "node_modules/memoize": { - "version": "10.1.0", - "resolved": "https://registry.npmjs.org/memoize/-/memoize-10.1.0.tgz", - "integrity": "sha512-MMbFhJzh4Jlg/poq1si90XRlTZRDHVqdlz2mPyGJ6kqMpyHUyVpDd5gpFAvVehW64+RA1eKE9Yt8aSLY7w2Kgg==", - "dev": true, - "license": "MIT", - "dependencies": { - "mimic-function": "^5.0.1" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sindresorhus/memoize?sponsor=1" - } - }, "node_modules/meow": { "version": "13.2.0", "resolved": "https://registry.npmjs.org/meow/-/meow-13.2.0.tgz", @@ -4965,19 +4130,6 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, - "node_modules/mimic-function": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/mimic-function/-/mimic-function-5.0.1.tgz", - "integrity": "sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/minimatch": { "version": "3.1.5", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", @@ -5001,19 +4153,6 @@ "node": ">=16 || 14 >=14.17" } }, - "node_modules/minizlib": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/minizlib/-/minizlib-3.1.0.tgz", - "integrity": "sha512-KZxYo1BUkWD2TVFLr0MQoM8vUUigWD3LlD83a/75BqC+4qE0Hb1Vo5v1FgcfaNXvfXzr+5EhQ6ing/CaBijTlw==", - "dev": true, - "license": "MIT", - "dependencies": { - "minipass": "^7.1.2" - }, - "engines": { - "node": ">= 18" - } - }, "node_modules/mitt": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/mitt/-/mitt-3.0.1.tgz", @@ -5067,39 +4206,6 @@ "dev": true, "license": "MIT" }, - "node_modules/node-fetch": { - "version": "2.7.0", - "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", - "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", - "dev": true, - "license": "MIT", - "dependencies": { - "whatwg-url": "^5.0.0" - }, - "engines": { - "node": "4.x || >=6.0.0" - }, - "peerDependencies": { - "encoding": "^0.1.0" - }, - "peerDependenciesMeta": { - "encoding": { - "optional": true - } - } - }, - "node_modules/node-gyp-build": { - "version": "4.8.4", - "resolved": "https://registry.npmjs.org/node-gyp-build/-/node-gyp-build-4.8.4.tgz", - "integrity": "sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ==", - "dev": true, - "license": "MIT", - "bin": { - "node-gyp-build": "bin.js", - "node-gyp-build-optional": "optional.js", - "node-gyp-build-test": "build-test.js" - } - }, "node_modules/node-notifier": { "version": "10.0.1", "resolved": "https://registry.npmjs.org/node-notifier/-/node-notifier-10.0.1.tgz", @@ -5115,32 +4221,6 @@ "which": "^2.0.2" } }, - "node_modules/nofilter": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/nofilter/-/nofilter-3.1.0.tgz", - "integrity": "sha512-l2NNj07e9afPnhAhvgVrCD/oy2Ai1yfLpuo3EpiO1jFTsB4sFz6oIfAfSZyQzVpkZQ9xS8ZS5g1jCBgq4Hwo0g==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12.19" - } - }, - "node_modules/nopt": { - "version": "8.1.0", - "resolved": "https://registry.npmjs.org/nopt/-/nopt-8.1.0.tgz", - "integrity": "sha512-ieGu42u/Qsa4TFktmaKEwM6MQH0pOWnaB3htzh0JRtx84+Mebc0cbZYN5bC+6WTZ4+77xrL9Pn5m7CV6VIkV7A==", - "dev": true, - "license": "ISC", - "dependencies": { - "abbrev": "^3.0.0" - }, - "bin": { - "nopt": "bin/nopt.js" - }, - "engines": { - "node": "^18.17.0 || >=20.5.0" - } - }, "node_modules/normalize-path": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", @@ -5201,36 +4281,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/p-map": { - "version": "7.0.3", - "resolved": "https://registry.npmjs.org/p-map/-/p-map-7.0.3.tgz", - "integrity": "sha512-VkndIv2fIB99swvQoA65bm+fsmt6UNdGeIB0oxBs+WhAhdh08QA04JXpI7rbB9r08/nkbysKoya9rtDERYOYMA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/package-config": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/package-config/-/package-config-5.0.0.tgz", - "integrity": "sha512-GYTTew2slBcYdvRHqjhwaaydVMvn/qrGC323+nKclYioNSLTDUM/lGgtGTgyHVtYcozb+XkE8CNhwcraOmZ9Mg==", - "dev": true, - "license": "MIT", - "dependencies": { - "find-up-simple": "^1.0.0", - "load-json-file": "^7.0.1" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/package-json-from-dist": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", @@ -5305,19 +4355,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/parse-ms": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/parse-ms/-/parse-ms-4.0.0.tgz", - "integrity": "sha512-TXfryirbmq34y8QBwgqCVLi+8oA3oWx2eAnSn62ITyEhEYaWRlVZ2DvMM9eZbMs/RfxPu/PK/aBLyGj4IrqMHw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/path-exists": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", @@ -5365,19 +4402,6 @@ "url": "https://opencollective.com/express" } }, - "node_modules/path-type": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/path-type/-/path-type-6.0.0.tgz", - "integrity": "sha512-Vj7sf++t5pBD637NSfkxpHSMfWaeig5+DKWLhcqIYx6mWQz5hdJTGDVMQiJcw1ZYkhs7AazKDGpRVji1LJCZUQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -5385,45 +4409,6 @@ "dev": true, "license": "ISC" }, - "node_modules/picomatch": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", - "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, - "node_modules/pirates": { - "version": "4.0.7", - "resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.7.tgz", - "integrity": "sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 6" - } - }, - "node_modules/plur": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/plur/-/plur-5.1.0.tgz", - "integrity": "sha512-VP/72JeXqak2KiOzjgKtQen5y3IZHn+9GOuLDafPv0eXa47xq0At93XahYBs26MsifCQ4enGKwbjBTKgb9QJXg==", - "dev": true, - "license": "MIT", - "dependencies": { - "irregular-plurals": "^3.3.0" - }, - "engines": { - "node": "^12.20.0 || ^14.13.1 || >=16.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/postcss": { "version": "8.5.6", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", @@ -5633,22 +4618,6 @@ } } }, - "node_modules/pretty-ms": { - "version": "9.2.0", - "resolved": "https://registry.npmjs.org/pretty-ms/-/pretty-ms-9.2.0.tgz", - "integrity": "sha512-4yf0QO/sllf/1zbZWYnvWw3NxCQwLXKzIj0G849LSufP15BXKM0rbD2Z3wVnkMfjdn/CB0Dpp444gYAACdsplg==", - "dev": true, - "license": "MIT", - "dependencies": { - "parse-ms": "^4.0.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/property-information": { "version": "7.1.0", "resolved": "https://registry.npmjs.org/property-information/-/property-information-7.1.0.tgz", @@ -5814,19 +4783,6 @@ "node": ">=0.10.0" } }, - "node_modules/resolve-cwd": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/resolve-cwd/-/resolve-cwd-3.0.0.tgz", - "integrity": "sha512-OrZaX2Mb+rJCpH/6CpSqt9xFVpN++x01XnN2ie9g6P5/3xelLAkXWVADpdz1IHD/KFfEXyE6V0U01OQ3UO2rEg==", - "dev": true, - "license": "MIT", - "dependencies": { - "resolve-from": "^5.0.0" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/resolve-from": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-5.0.0.tgz", @@ -5837,6 +4793,16 @@ "node": ">=8" } }, + "node_modules/resolve-pkg-maps": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz", + "integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" + } + }, "node_modules/reusify": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", @@ -5848,26 +4814,6 @@ "node": ">=0.10.0" } }, - "node_modules/rimraf": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-6.0.1.tgz", - "integrity": "sha512-9dkvaxAsk/xNXSJzMgFqqMCuFgt2+KsOFek3TMLfo8NCPfWpBmqwyNn5Y+NX56QUYfCtsyhF3ayiboEoUmJk/A==", - "dev": true, - "license": "ISC", - "dependencies": { - "glob": "^11.0.0", - "package-json-from-dist": "^1.0.0" - }, - "bin": { - "rimraf": "dist/esm/bin.mjs" - }, - "engines": { - "node": "20 || >=22" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, "node_modules/run-parallel": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", @@ -5920,22 +4866,6 @@ "node": ">=10" } }, - "node_modules/serialize-error": { - "version": "7.0.1", - "resolved": "https://registry.npmjs.org/serialize-error/-/serialize-error-7.0.1.tgz", - "integrity": "sha512-8I8TjW5KMOKsZQTvoxjuSIa7foAwPWGOts+6o7sgjz41/qMD9VQHEDxi6PBvK2l0MXUmqZyNpUK+T2tQaaElvw==", - "dev": true, - "license": "MIT", - "dependencies": { - "type-fest": "^0.13.1" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/shebang-command": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", @@ -5979,236 +4909,34 @@ "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/slash": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/slash/-/slash-5.1.0.tgz", - "integrity": "sha512-ZA6oR3T/pEyuqwMgAKT0/hAv8oAXckzbkmR0UkUosQ+Mc4RxGoJkRmwHgHufaenlyAgE1Mxgpdcrf75y6XcnDg==", + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", "dev": true, - "license": "MIT", + "license": "BSD-3-Clause", "engines": { - "node": ">=14.16" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "node": ">=0.10.0" } }, - "node_modules/slice-ansi": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-5.0.0.tgz", - "integrity": "sha512-FC+lgizVPfie0kkhqUScwRu1O/lF6NOgJmlCgK+/LYxDCTk8sGelYaHDhFcDN+Sn3Cv+3VSa4Byeo+IMCzpMgQ==", - "dev": true, + "node_modules/space-separated-tokens": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/space-separated-tokens/-/space-separated-tokens-2.0.2.tgz", + "integrity": "sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q==", "license": "MIT", - "dependencies": { - "ansi-styles": "^6.0.0", - "is-fullwidth-code-point": "^4.0.0" - }, - "engines": { - "node": ">=12" - }, "funding": { - "url": "https://github.com/chalk/slice-ansi?sponsor=1" + "type": "github", + "url": "https://github.com/sponsors/wooorm" } }, - "node_modules/sock-daemon": { - "version": "1.4.2", - "resolved": "https://registry.npmjs.org/sock-daemon/-/sock-daemon-1.4.2.tgz", - "integrity": "sha512-IzbegWshWWR+UzQ7487mbdYNmfJ1jXUXQBUHooqtpylO+aW0vMVbFN2d2ug3CSPZ0wbG7ZTTGwpUuthIDFIOGg==", + "node_modules/string-argv": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/string-argv/-/string-argv-0.3.2.tgz", + "integrity": "sha512-aqD2Q0144Z+/RqG52NeHEkZauTAUWJO8c6yTftGJKO3Tja5tUgIfmIl6kExvhtxSDP7fXB6DvzkfMpCd/F3G+Q==", "dev": true, - "license": "BlueOak-1.0.0", - "dependencies": { - "rimraf": "^5.0.5", - "signal-exit": "^4.1.0", - "socket-post-message": "^1.0.3" - }, + "license": "MIT", "engines": { - "node": "16 >=16.17.0 || 18 >= 18.6.0 || >=20" - } - }, - "node_modules/sock-daemon/node_modules/brace-expansion": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.0.tgz", - "integrity": "sha512-TN1kCZAgdgweJhWWpgKYrQaMNHcDULHkWwQIspdtjV4Y5aurRdZpjAqn6yX3FPqTA9ngHCc4hJxMAMgGfve85w==", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^1.0.0" - } - }, - "node_modules/sock-daemon/node_modules/glob": { - "version": "10.5.0", - "resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz", - "integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==", - "dev": true, - "license": "ISC", - "dependencies": { - "foreground-child": "^3.1.0", - "jackspeak": "^3.1.2", - "minimatch": "^9.0.4", - "minipass": "^7.1.2", - "package-json-from-dist": "^1.0.0", - "path-scurry": "^1.11.1" - }, - "bin": { - "glob": "dist/esm/bin.mjs" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/sock-daemon/node_modules/jackspeak": { - "version": "3.4.3", - "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz", - "integrity": "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==", - "dev": true, - "license": "BlueOak-1.0.0", - "dependencies": { - "@isaacs/cliui": "^8.0.2" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - }, - "optionalDependencies": { - "@pkgjs/parseargs": "^0.11.0" - } - }, - "node_modules/sock-daemon/node_modules/lru-cache": { - "version": "10.4.3", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", - "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", - "dev": true, - "license": "ISC" - }, - "node_modules/sock-daemon/node_modules/minimatch": { - "version": "9.0.9", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", - "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", - "dev": true, - "license": "ISC", - "dependencies": { - "brace-expansion": "^2.0.2" - }, - "engines": { - "node": ">=16 || 14 >=14.17" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/sock-daemon/node_modules/path-scurry": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz", - "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==", - "dev": true, - "license": "BlueOak-1.0.0", - "dependencies": { - "lru-cache": "^10.2.0", - "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" - }, - "engines": { - "node": ">=16 || 14 >=14.18" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/sock-daemon/node_modules/rimraf": { - "version": "5.0.10", - "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-5.0.10.tgz", - "integrity": "sha512-l0OE8wL34P4nJH/H2ffoaniAokM2qSmrtXHmlpvYr5AVVX8msAyW0l8NVJFDxlSK4u3Uh/f41cQheDVdnYijwQ==", - "dev": true, - "license": "ISC", - "dependencies": { - "glob": "^10.3.7" - }, - "bin": { - "rimraf": "dist/esm/bin.mjs" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/socket-post-message": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/socket-post-message/-/socket-post-message-1.0.3.tgz", - "integrity": "sha512-UhJaB3xR2oF+HvddFOq2cBZi4zVKOHvdiBo+BaScNxsEUg3TLWSP8BkweKfe07kfH1thjn1hJR0af/w1EtBFjg==", - "dev": true - }, - "node_modules/source-map-js": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", - "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", - "dev": true, - "license": "BSD-3-Clause", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/space-separated-tokens": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/space-separated-tokens/-/space-separated-tokens-2.0.2.tgz", - "integrity": "sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q==", - "license": "MIT", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - }, - "node_modules/sprintf-js": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz", - "integrity": "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==", - "dev": true, - "license": "BSD-3-Clause" - }, - "node_modules/stack-utils": { - "version": "2.0.6", - "resolved": "https://registry.npmjs.org/stack-utils/-/stack-utils-2.0.6.tgz", - "integrity": "sha512-XlkWvfIm6RmsWtNJx+uqtKLS8eqFbxUg0ZzLXqY0caEy9l7hruX8IpiDnjsLavoBgqCCR71TqWO8MaXYheJ3RQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "escape-string-regexp": "^2.0.0" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/stack-utils/node_modules/escape-string-regexp": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-2.0.0.tgz", - "integrity": "sha512-UpzcLCXolUWcNu5HtVMHYdXJjArjsF9C0aNnquZYY4uW/Vu0miy5YoWvbV345HauVvcAUnpRuhMMcqTcGOY2+w==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/string-argv": { - "version": "0.3.2", - "resolved": "https://registry.npmjs.org/string-argv/-/string-argv-0.3.2.tgz", - "integrity": "sha512-aqD2Q0144Z+/RqG52NeHEkZauTAUWJO8c6yTftGJKO3Tja5tUgIfmIl6kExvhtxSDP7fXB6DvzkfMpCd/F3G+Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.6.19" - } - }, - "node_modules/string-width": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", - "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "emoji-regex": "^10.3.0", - "get-east-asian-width": "^1.0.0", - "strip-ansi": "^7.1.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "node": ">=0.6.19" } }, "node_modules/string-width-cjs": { @@ -6593,46 +5321,6 @@ "node": "^14.17.0 || ^16.13.0 || >=18.0.0" } }, - "node_modules/supertap": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/supertap/-/supertap-3.0.1.tgz", - "integrity": "sha512-u1ZpIBCawJnO+0QePsEiOknOfCRq0yERxiAchT0i4li0WHNUJbf0evXXSXOcCAR4M8iMDoajXYmstm/qO81Isw==", - "dev": true, - "license": "MIT", - "dependencies": { - "indent-string": "^5.0.0", - "js-yaml": "^3.14.1", - "serialize-error": "^7.0.1", - "strip-ansi": "^7.0.1" - }, - "engines": { - "node": "^12.20.0 || ^14.13.1 || >=16.0.0" - } - }, - "node_modules/supertap/node_modules/argparse": { - "version": "1.0.10", - "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz", - "integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==", - "dev": true, - "license": "MIT", - "dependencies": { - "sprintf-js": "~1.0.2" - } - }, - "node_modules/supertap/node_modules/js-yaml": { - "version": "3.14.2", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.2.tgz", - "integrity": "sha512-PMSmkqxr106Xa156c2M265Z+FTrPl+oxd/rgOQy2tijQeK5TxQ43psO1ZCwhVOSdnn+RzkzlRz/eY4BgJBYVpg==", - "dev": true, - "license": "MIT", - "dependencies": { - "argparse": "^1.0.7", - "esprima": "^4.0.0" - }, - "bin": { - "js-yaml": "bin/js-yaml.js" - } - }, "node_modules/supports-color": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", @@ -6765,195 +5453,638 @@ "is-fullwidth-code-point": "^3.0.0" }, "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/slice-ansi?sponsor=1" + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/slice-ansi?sponsor=1" + } + }, + "node_modules/table/node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/table/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/temp-dir": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/temp-dir/-/temp-dir-3.0.0.tgz", + "integrity": "sha512-nHc6S/bwIilKHNRgK/3jlhDoIHcp45YgyiwcAk46Tr0LfEqGBVpmiAyuiuxeVE44m3mXnEeVhaipLOEWmH+Njw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.16" + } + }, + "node_modules/tempy": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/tempy/-/tempy-3.1.0.tgz", + "integrity": "sha512-7jDLIdD2Zp0bDe5r3D2qtkd1QOCacylBuL7oa4udvN6v2pqr4+LcCr67C8DR1zkpaZ8XosF5m1yQSabKAW6f2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-stream": "^3.0.0", + "temp-dir": "^3.0.0", + "type-fest": "^2.12.2", + "unique-string": "^3.0.0" + }, + "engines": { + "node": ">=14.16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/tempy/node_modules/type-fest": { + "version": "2.19.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-2.19.0.tgz", + "integrity": "sha512-RAH822pAdBgcNMAfWnCBU3CFZcfZ/i1eZjwFU/dsLKumyuuP3niueg2UAukXYF0E2AAoc82ZSSf9J0WQBinzHA==", + "dev": true, + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=12.20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/trim-lines": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/trim-lines/-/trim-lines-3.0.1.tgz", + "integrity": "sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/trough": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/trough/-/trough-2.2.0.tgz", + "integrity": "sha512-tmMpK00BjZiUyVyvrBK7knerNgmgvcV/KLVyuma/SC+TQN167GrMRciANTz09+k3zW8L8t60jWO1GpfkZdjTaw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/ts-api-utils": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.1.0.tgz", + "integrity": "sha512-CUgTZL1irw8u29bzrOD/nH85jqyc74D6SshFgujOIA7osm2Rz7dYH77agkx7H4FBNxDq7Cjf+IjaX/8zwFW+ZQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.12" + }, + "peerDependencies": { + "typescript": ">=4.8.4" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/tsx": { + "version": "4.21.0", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.21.0.tgz", + "integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "~0.27.0", + "get-tsconfig": "^4.7.5" + }, + "bin": { + "tsx": "dist/cli.mjs" + }, + "engines": { + "node": ">=18.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + } + }, + "node_modules/tsx/node_modules/@esbuild/aix-ppc64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.7.tgz", + "integrity": "sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/android-arm": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.7.tgz", + "integrity": "sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/android-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.7.tgz", + "integrity": "sha512-62dPZHpIXzvChfvfLJow3q5dDtiNMkwiRzPylSCfriLvZeq0a1bWChrGx/BbUbPwOrsWKMn8idSllklzBy+dgQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/android-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.7.tgz", + "integrity": "sha512-x5VpMODneVDb70PYV2VQOmIUUiBtY3D3mPBG8NxVk5CogneYhkR7MmM3yR/uMdITLrC1ml/NV1rj4bMJuy9MCg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/darwin-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.7.tgz", + "integrity": "sha512-5lckdqeuBPlKUwvoCXIgI2D9/ABmPq3Rdp7IfL70393YgaASt7tbju3Ac+ePVi3KDH6N2RqePfHnXkaDtY9fkw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/darwin-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.7.tgz", + "integrity": "sha512-rYnXrKcXuT7Z+WL5K980jVFdvVKhCHhUwid+dDYQpH+qu+TefcomiMAJpIiC2EM3Rjtq0sO3StMV/+3w3MyyqQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/freebsd-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.7.tgz", + "integrity": "sha512-B48PqeCsEgOtzME2GbNM2roU29AMTuOIN91dsMO30t+Ydis3z/3Ngoj5hhnsOSSwNzS+6JppqWsuhTp6E82l2w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/freebsd-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.7.tgz", + "integrity": "sha512-jOBDK5XEjA4m5IJK3bpAQF9/Lelu/Z9ZcdhTRLf4cajlB+8VEhFFRjWgfy3M1O4rO2GQ/b2dLwCUGpiF/eATNQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-arm": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.7.tgz", + "integrity": "sha512-RkT/YXYBTSULo3+af8Ib0ykH8u2MBh57o7q/DAs3lTJlyVQkgQvlrPTnjIzzRPQyavxtPtfg0EopvDyIt0j1rA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.7.tgz", + "integrity": "sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-ia32": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.7.tgz", + "integrity": "sha512-GA48aKNkyQDbd3KtkplYWT102C5sn/EZTY4XROkxONgruHPU72l+gW+FfF8tf2cFjeHaRbWpOYa/uRBz/Xq1Pg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-loong64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.7.tgz", + "integrity": "sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-mips64el": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.7.tgz", + "integrity": "sha512-KabT5I6StirGfIz0FMgl1I+R1H73Gp0ofL9A3nG3i/cYFJzKHhouBV5VWK1CSgKvVaG4q1RNpCTR2LuTVB3fIw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-ppc64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.7.tgz", + "integrity": "sha512-gRsL4x6wsGHGRqhtI+ifpN/vpOFTQtnbsupUF5R5YTAg+y/lKelYR1hXbnBdzDjGbMYjVJLJTd2OFmMewAgwlQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-riscv64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.7.tgz", + "integrity": "sha512-hL25LbxO1QOngGzu2U5xeXtxXcW+/GvMN3ejANqXkxZ/opySAZMrc+9LY/WyjAan41unrR3YrmtTsUpwT66InQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" } }, - "node_modules/table/node_modules/string-width": { - "version": "4.2.3", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", - "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "node_modules/tsx/node_modules/@esbuild/linux-s390x": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.7.tgz", + "integrity": "sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw==", + "cpu": [ + "s390x" + ], "dev": true, "license": "MIT", - "dependencies": { - "emoji-regex": "^8.0.0", - "is-fullwidth-code-point": "^3.0.0", - "strip-ansi": "^6.0.1" - }, + "optional": true, + "os": [ + "linux" + ], "engines": { - "node": ">=8" + "node": ">=18" } }, - "node_modules/table/node_modules/strip-ansi": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", - "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "node_modules/tsx/node_modules/@esbuild/linux-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.7.tgz", + "integrity": "sha512-hzznmADPt+OmsYzw1EE33ccA+HPdIqiCRq7cQeL1Jlq2gb1+OyWBkMCrYGBJ+sxVzve2ZJEVeePbLM2iEIZSxA==", + "cpu": [ + "x64" + ], "dev": true, "license": "MIT", - "dependencies": { - "ansi-regex": "^5.0.1" - }, + "optional": true, + "os": [ + "linux" + ], "engines": { - "node": ">=8" + "node": ">=18" } }, - "node_modules/tar": { - "version": "7.5.11", - "resolved": "https://registry.npmjs.org/tar/-/tar-7.5.11.tgz", - "integrity": "sha512-ChjMH33/KetonMTAtpYdgUFr0tbz69Fp2v7zWxQfYZX4g5ZN2nOBXm1R2xyA+lMIKrLKIoKAwFj93jE/avX9cQ==", + "node_modules/tsx/node_modules/@esbuild/netbsd-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.7.tgz", + "integrity": "sha512-b6pqtrQdigZBwZxAn1UpazEisvwaIDvdbMbmrly7cDTMFnw/+3lVxxCTGOrkPVnsYIosJJXAsILG9XcQS+Yu6w==", + "cpu": [ + "arm64" + ], "dev": true, - "license": "BlueOak-1.0.0", - "dependencies": { - "@isaacs/fs-minipass": "^4.0.0", - "chownr": "^3.0.0", - "minipass": "^7.1.2", - "minizlib": "^3.1.0", - "yallist": "^5.0.0" - }, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], "engines": { "node": ">=18" } }, - "node_modules/temp-dir": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/temp-dir/-/temp-dir-3.0.0.tgz", - "integrity": "sha512-nHc6S/bwIilKHNRgK/3jlhDoIHcp45YgyiwcAk46Tr0LfEqGBVpmiAyuiuxeVE44m3mXnEeVhaipLOEWmH+Njw==", + "node_modules/tsx/node_modules/@esbuild/netbsd-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.7.tgz", + "integrity": "sha512-OfatkLojr6U+WN5EDYuoQhtM+1xco+/6FSzJJnuWiUw5eVcicbyK3dq5EeV/QHT1uy6GoDhGbFpprUiHUYggrw==", + "cpu": [ + "x64" + ], "dev": true, "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], "engines": { - "node": ">=14.16" + "node": ">=18" } }, - "node_modules/tempy": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/tempy/-/tempy-3.1.0.tgz", - "integrity": "sha512-7jDLIdD2Zp0bDe5r3D2qtkd1QOCacylBuL7oa4udvN6v2pqr4+LcCr67C8DR1zkpaZ8XosF5m1yQSabKAW6f2g==", + "node_modules/tsx/node_modules/@esbuild/openbsd-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.7.tgz", + "integrity": "sha512-AFuojMQTxAz75Fo8idVcqoQWEHIXFRbOc1TrVcFSgCZtQfSdc1RXgB3tjOn/krRHENUB4j00bfGjyl2mJrU37A==", + "cpu": [ + "arm64" + ], "dev": true, "license": "MIT", - "dependencies": { - "is-stream": "^3.0.0", - "temp-dir": "^3.0.0", - "type-fest": "^2.12.2", - "unique-string": "^3.0.0" - }, + "optional": true, + "os": [ + "openbsd" + ], "engines": { - "node": ">=14.16" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "node": ">=18" } }, - "node_modules/tempy/node_modules/type-fest": { - "version": "2.19.0", - "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-2.19.0.tgz", - "integrity": "sha512-RAH822pAdBgcNMAfWnCBU3CFZcfZ/i1eZjwFU/dsLKumyuuP3niueg2UAukXYF0E2AAoc82ZSSf9J0WQBinzHA==", + "node_modules/tsx/node_modules/@esbuild/openbsd-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.7.tgz", + "integrity": "sha512-+A1NJmfM8WNDv5CLVQYJ5PshuRm/4cI6WMZRg1by1GwPIQPCTs1GLEUHwiiQGT5zDdyLiRM/l1G0Pv54gvtKIg==", + "cpu": [ + "x64" + ], "dev": true, - "license": "(MIT OR CC0-1.0)", + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], "engines": { - "node": ">=12.20" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "node": ">=18" } }, - "node_modules/time-zone": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/time-zone/-/time-zone-1.0.0.tgz", - "integrity": "sha512-TIsDdtKo6+XrPtiTm1ssmMngN1sAhyKnTO2kunQWqNPWIVvCm15Wmw4SWInwTVgJ5u/Tr04+8Ei9TNcw4x4ONA==", + "node_modules/tsx/node_modules/@esbuild/openharmony-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.7.tgz", + "integrity": "sha512-+KrvYb/C8zA9CU/g0sR6w2RBw7IGc5J2BPnc3dYc5VJxHCSF1yNMxTV5LQ7GuKteQXZtspjFbiuW5/dOj7H4Yw==", + "cpu": [ + "arm64" + ], "dev": true, "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], "engines": { - "node": ">=4" + "node": ">=18" } }, - "node_modules/to-regex-range": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", - "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "node_modules/tsx/node_modules/@esbuild/sunos-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.7.tgz", + "integrity": "sha512-ikktIhFBzQNt/QDyOL580ti9+5mL/YZeUPKU2ivGtGjdTYoqz6jObj6nOMfhASpS4GU4Q/Clh1QtxWAvcYKamA==", + "cpu": [ + "x64" + ], "dev": true, "license": "MIT", - "dependencies": { - "is-number": "^7.0.0" - }, + "optional": true, + "os": [ + "sunos" + ], "engines": { - "node": ">=8.0" + "node": ">=18" } }, - "node_modules/tr46": { - "version": "0.0.3", - "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", - "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==", + "node_modules/tsx/node_modules/@esbuild/win32-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.7.tgz", + "integrity": "sha512-7yRhbHvPqSpRUV7Q20VuDwbjW5kIMwTHpptuUzV+AA46kiPze5Z7qgt6CLCK3pWFrHeNfDd1VKgyP4O+ng17CA==", + "cpu": [ + "arm64" + ], "dev": true, - "license": "MIT" - }, - "node_modules/trim-lines": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/trim-lines/-/trim-lines-3.0.1.tgz", - "integrity": "sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg==", "license": "MIT", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" } }, - "node_modules/trough": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/trough/-/trough-2.2.0.tgz", - "integrity": "sha512-tmMpK00BjZiUyVyvrBK7knerNgmgvcV/KLVyuma/SC+TQN167GrMRciANTz09+k3zW8L8t60jWO1GpfkZdjTaw==", + "node_modules/tsx/node_modules/@esbuild/win32-ia32": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.7.tgz", + "integrity": "sha512-SmwKXe6VHIyZYbBLJrhOoCJRB/Z1tckzmgTLfFYOfpMAx63BJEaL9ExI8x7v0oAO3Zh6D/Oi1gVxEYr5oUCFhw==", + "cpu": [ + "ia32" + ], + "dev": true, "license": "MIT", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" } }, - "node_modules/ts-api-utils": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.1.0.tgz", - "integrity": "sha512-CUgTZL1irw8u29bzrOD/nH85jqyc74D6SshFgujOIA7osm2Rz7dYH77agkx7H4FBNxDq7Cjf+IjaX/8zwFW+ZQ==", + "node_modules/tsx/node_modules/@esbuild/win32-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.7.tgz", + "integrity": "sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg==", + "cpu": [ + "x64" + ], "dev": true, "license": "MIT", + "optional": true, + "os": [ + "win32" + ], "engines": { - "node": ">=18.12" - }, - "peerDependencies": { - "typescript": ">=4.8.4" + "node": ">=18" } }, - "node_modules/tsimp": { - "version": "2.0.12", - "resolved": "https://registry.npmjs.org/tsimp/-/tsimp-2.0.12.tgz", - "integrity": "sha512-0XbhMfDB1BlN4iuheUaCUVB2iAjWb9z6Ik/6WcxREc4MhjYmkScK+CRNf34wkDO8wMvmFBb0lYdrd8H44g9yjg==", + "node_modules/tsx/node_modules/esbuild": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.7.tgz", + "integrity": "sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w==", "dev": true, - "license": "BlueOak-1.0.0", - "dependencies": { - "@isaacs/cached": "^1.0.1", - "@isaacs/catcher": "^1.0.4", - "foreground-child": "^3.1.1", - "mkdirp": "^3.0.1", - "pirates": "^4.0.6", - "rimraf": "^6.0.1", - "signal-exit": "^4.1.0", - "sock-daemon": "^1.4.2", - "walk-up-path": "^4.0.0" - }, + "hasInstallScript": true, + "license": "MIT", "bin": { - "tsimp": "dist/esm/bin.mjs" + "esbuild": "bin/esbuild" }, "engines": { - "node": "16 >=16.17.0 || 18 >= 18.6.0 || >=20" + "node": ">=18" }, - "peerDependencies": { - "typescript": "^5.1.0" + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.27.7", + "@esbuild/android-arm": "0.27.7", + "@esbuild/android-arm64": "0.27.7", + "@esbuild/android-x64": "0.27.7", + "@esbuild/darwin-arm64": "0.27.7", + "@esbuild/darwin-x64": "0.27.7", + "@esbuild/freebsd-arm64": "0.27.7", + "@esbuild/freebsd-x64": "0.27.7", + "@esbuild/linux-arm": "0.27.7", + "@esbuild/linux-arm64": "0.27.7", + "@esbuild/linux-ia32": "0.27.7", + "@esbuild/linux-loong64": "0.27.7", + "@esbuild/linux-mips64el": "0.27.7", + "@esbuild/linux-ppc64": "0.27.7", + "@esbuild/linux-riscv64": "0.27.7", + "@esbuild/linux-s390x": "0.27.7", + "@esbuild/linux-x64": "0.27.7", + "@esbuild/netbsd-arm64": "0.27.7", + "@esbuild/netbsd-x64": "0.27.7", + "@esbuild/openbsd-arm64": "0.27.7", + "@esbuild/openbsd-x64": "0.27.7", + "@esbuild/openharmony-arm64": "0.27.7", + "@esbuild/sunos-x64": "0.27.7", + "@esbuild/win32-arm64": "0.27.7", + "@esbuild/win32-ia32": "0.27.7", + "@esbuild/win32-x64": "0.27.7" } }, - "node_modules/tslib": { - "version": "2.8.1", - "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", - "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", - "license": "0BSD" - }, "node_modules/type-check": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", @@ -6967,19 +6098,6 @@ "node": ">= 0.8.0" } }, - "node_modules/type-fest": { - "version": "0.13.1", - "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.13.1.tgz", - "integrity": "sha512-34R7HTnG0XIJcBSn5XhDd7nNFPRcXYRZrBB2O2jdKqYODldSzBAqzsWoZYYvduky73toYS/ESqxPvkDf/F0XMg==", - "dev": true, - "license": "(MIT OR CC0-1.0)", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/typed-css-modules": { "version": "0.9.1", "resolved": "https://registry.npmjs.org/typed-css-modules/-/typed-css-modules-0.9.1.tgz", @@ -7237,19 +6355,6 @@ "dev": true, "license": "MIT" }, - "node_modules/unicorn-magic": { - "version": "0.3.0", - "resolved": "https://registry.npmjs.org/unicorn-magic/-/unicorn-magic-0.3.0.tgz", - "integrity": "sha512-+QBBXBCvifc56fsbuxZQ6Sic3wqqc3WWaqxs58gvJrcOuN83HGTCwz3oS5phzU9LthRNE9VrJCFCLUgHeeFnfA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/unified": { "version": "11.0.5", "resolved": "https://registry.npmjs.org/unified/-/unified-11.0.5.tgz", @@ -7437,44 +6542,6 @@ "integrity": "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==", "license": "MIT" }, - "node_modules/walk-up-path": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/walk-up-path/-/walk-up-path-4.0.0.tgz", - "integrity": "sha512-3hu+tD8YzSLGuFYtPRb48vdhKMi0KQV5sn+uWr8+7dMEq/2G/dtLrdDinkLjqq5TIbIBjYJ4Ax/n3YiaW7QM8A==", - "dev": true, - "license": "ISC", - "engines": { - "node": "20 || >=22" - } - }, - "node_modules/webidl-conversions": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", - "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==", - "dev": true, - "license": "BSD-2-Clause" - }, - "node_modules/well-known-symbols": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/well-known-symbols/-/well-known-symbols-2.0.0.tgz", - "integrity": "sha512-ZMjC3ho+KXo0BfJb7JgtQ5IBuvnShdlACNkKkdsqBmYw3bPAaJfPeYUo6tLUaT5tG/Gkh7xkpBhKRQ9e7pyg9Q==", - "dev": true, - "license": "ISC", - "engines": { - "node": ">=6" - } - }, - "node_modules/whatwg-url": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", - "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", - "dev": true, - "license": "MIT", - "dependencies": { - "tr46": "~0.0.3", - "webidl-conversions": "^3.0.0" - } - }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", @@ -7648,20 +6715,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/write-file-atomic": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/write-file-atomic/-/write-file-atomic-6.0.0.tgz", - "integrity": "sha512-GmqrO8WJ1NuzJ2DrziEI2o57jKAVIQNf8a18W3nCYU3H7PNWqCCVTeH6/NQE93CIllIgQS98rrmVkYgTX9fFJQ==", - "dev": true, - "license": "ISC", - "dependencies": { - "imurmurhash": "^0.1.4", - "signal-exit": "^4.0.1" - }, - "engines": { - "node": "^18.17.0 || >=20.5.0" - } - }, "node_modules/y18n": { "version": "5.0.8", "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", @@ -7672,16 +6725,6 @@ "node": ">=10" } }, - "node_modules/yallist": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/yallist/-/yallist-5.0.0.tgz", - "integrity": "sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw==", - "dev": true, - "license": "BlueOak-1.0.0", - "engines": { - "node": ">=18" - } - }, "node_modules/yargs": { "version": "17.7.2", "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", @@ -7807,8 +6850,7 @@ }, "devDependencies": { "@types/node": "^22", - "ava": "^6.4.1", - "tsimp": "^2.0.12", + "tsx": "^4.21.0", "typescript": "^5.9.2" }, "engines": { diff --git a/package.json b/package.json index ad583438..cdf98de8 100644 --- a/package.json +++ b/package.json @@ -1,17 +1,8 @@ { - "ava": { - "extensions": { - "ts": "module" - }, - "nodeArguments": [ - "--import=tsimp" - ] - }, "devDependencies": { "@types/hotwired__turbo": "^8", "@types/react": "^19.1.10", "@types/react-dom": "^19.1.7", - "ava": "^6.4.1", "esbuild": "^0.25.8", "eslint": "^9.33.0", "eslint-plugin-react-hooks": "^5.2.0", @@ -25,7 +16,6 @@ "stylelint": "^16.23.1", "stylelint-config-recommended": "^17.0.0", "tempy": "^3.1.0", - "tsimp": "^2.0.12", "tslib": "^2.8.1", "typed-css-modules": "^0.9.1", "typescript": "^5.9.2", diff --git a/paddler_client_javascript/.gitignore b/paddler_client_javascript/.gitignore index 7469f1d3..10ce1293 100644 --- a/paddler_client_javascript/.gitignore +++ b/paddler_client_javascript/.gitignore @@ -1,6 +1,5 @@ node_modules/ dist/ -.tsimp/ *.tsbuildinfo *.log coverage/ diff --git a/paddler_client_javascript/package.json b/paddler_client_javascript/package.json index 5c63a42b..64d92f4e 100644 --- a/paddler_client_javascript/package.json +++ b/paddler_client_javascript/package.json @@ -20,7 +20,7 @@ "files": ["dist", "README.md", "LICENSE"], "scripts": { "build": "tsc -p .", - "test": "ava", + "test": "tsc -p tsconfig.test.json && tsx --test", "prepack": "tsc -p ." }, "peerDependencies": { @@ -32,18 +32,10 @@ }, "devDependencies": { "@types/node": "^22", - "ava": "^6.4.1", - "tsimp": "^2.0.12", + "tsx": "^4.21.0", "typescript": "^5.9.2" }, "engines": { "node": ">=22" - }, - "ava": { - "extensions": { - "ts": "module" - }, - "nodeArguments": ["--import=tsimp"], - "files": ["tests/**/*.test.ts"] } } diff --git a/paddler_client_javascript/tests/PaddlerError.test.ts b/paddler_client_javascript/tests/PaddlerError.test.ts index 66da35fa..727f8363 100644 --- a/paddler_client_javascript/tests/PaddlerError.test.ts +++ b/paddler_client_javascript/tests/PaddlerError.test.ts @@ -1,4 +1,5 @@ -import test from "ava"; +import { ok, strictEqual } from "node:assert/strict"; +import { test } from "node:test"; import { ConnectionDroppedError } from "../src/ConnectionDroppedError"; import { HttpError } from "../src/HttpError"; @@ -7,43 +8,43 @@ import { PaddlerError } from "../src/PaddlerError"; import { ServerError } from "../src/ServerError"; import { WebSocketError } from "../src/WebSocketError"; -test("HttpError extends PaddlerError and carries the status code", function (t) { +test("HttpError extends PaddlerError and carries the status code", function () { const err = new HttpError(503, "Service Unavailable"); - t.true(err instanceof PaddlerError); - t.true(err instanceof Error); - t.is(err.statusCode, 503); - t.is(err.message, "Service Unavailable"); - t.is(err.name, "HttpError"); + ok(err instanceof PaddlerError); + ok(err instanceof Error); + strictEqual(err.statusCode, 503); + strictEqual(err.message, "Service Unavailable"); + strictEqual(err.name, "HttpError"); }); -test("JsonError carries raw payload alongside its message", function (t) { +test("JsonError carries raw payload alongside its message", function () { const err = new JsonError("unexpected token", "{not-json"); - t.true(err instanceof PaddlerError); - t.is(err.raw, "{not-json"); - t.is(err.name, "JsonError"); + ok(err instanceof PaddlerError); + strictEqual(err.raw, "{not-json"); + strictEqual(err.name, "JsonError"); }); -test("WebSocketError is a distinct subclass", function (t) { +test("WebSocketError is a distinct subclass", function () { const err = new WebSocketError("socket closed"); - t.true(err instanceof PaddlerError); - t.is(err.name, "WebSocketError"); + ok(err instanceof PaddlerError); + strictEqual(err.name, "WebSocketError"); }); -test("ConnectionDroppedError carries the request id", function (t) { +test("ConnectionDroppedError carries the request id", function () { const err = new ConnectionDroppedError("req-1"); - t.true(err instanceof PaddlerError); - t.is(err.requestId, "req-1"); - t.true(err.message.includes("req-1")); + ok(err instanceof PaddlerError); + strictEqual(err.requestId, "req-1"); + ok(err.message.includes("req-1")); }); -test("ServerError carries an integer code", function (t) { +test("ServerError carries an integer code", function () { const err = new ServerError(429, "rate limit"); - t.true(err instanceof PaddlerError); - t.is(err.code, 429); - t.is(err.message, "rate limit"); + ok(err instanceof PaddlerError); + strictEqual(err.code, 429); + strictEqual(err.message, "rate limit"); }); diff --git a/paddler_client_javascript/tests/extractHuggingFaceUrlParts.test.ts b/paddler_client_javascript/tests/extractHuggingFaceUrlParts.test.ts index ce71a2b1..49e3d557 100644 --- a/paddler_client_javascript/tests/extractHuggingFaceUrlParts.test.ts +++ b/paddler_client_javascript/tests/extractHuggingFaceUrlParts.test.ts @@ -1,47 +1,48 @@ -import test from "ava"; +import { deepStrictEqual, throws } from "node:assert/strict"; +import { test } from "node:test"; import { extractHuggingFaceUrlParts } from "../src/extractHuggingFaceUrlParts"; -test("blob URL extracts owner, repo, revision and filename", function (t) { +test("blob URL extracts owner, repo, revision and filename", function () { const url = new URL( "https://huggingface.co/Qwen/Qwen3-0.6B-GGUF/blob/main/Qwen3-0.6B-Q8_0.gguf", ); - t.deepEqual(extractHuggingFaceUrlParts(url), { + deepStrictEqual(extractHuggingFaceUrlParts(url), { filename: "Qwen3-0.6B-Q8_0.gguf", repo_id: "Qwen/Qwen3-0.6B-GGUF", revision: "main", }); }); -test("resolve URL extracts the same fields", function (t) { +test("resolve URL extracts the same fields", function () { const url = new URL( "https://huggingface.co/Qwen/Qwen3-0.6B-GGUF/resolve/main/Qwen3-0.6B-Q8_0.gguf", ); - t.deepEqual(extractHuggingFaceUrlParts(url), { + deepStrictEqual(extractHuggingFaceUrlParts(url), { filename: "Qwen3-0.6B-Q8_0.gguf", repo_id: "Qwen/Qwen3-0.6B-GGUF", revision: "main", }); }); -test("nested filename paths preserve every segment", function (t) { +test("nested filename paths preserve every segment", function () { const url = new URL( "https://huggingface.co/owner/repo/blob/main/dir/sub/file.gguf", ); - t.deepEqual(extractHuggingFaceUrlParts(url), { + deepStrictEqual(extractHuggingFaceUrlParts(url), { filename: "dir/sub/file.gguf", repo_id: "owner/repo", revision: "main", }); }); -test("malformed URLs throw", function (t) { +test("malformed URLs throw", function () { const url = new URL("https://huggingface.co/owner/repo"); - t.throws(function () { + throws(function () { extractHuggingFaceUrlParts(url); }); }); diff --git a/paddler_client_javascript/tests/fetchJson.test.ts b/paddler_client_javascript/tests/fetchJson.test.ts index 3f2014b3..c1b0a1fb 100644 --- a/paddler_client_javascript/tests/fetchJson.test.ts +++ b/paddler_client_javascript/tests/fetchJson.test.ts @@ -1,5 +1,6 @@ -import test from "ava"; +import { deepStrictEqual, rejects } from "node:assert/strict"; import { createServer, type RequestListener, type Server } from "node:http"; +import { test } from "node:test"; import { z } from "zod"; import { fetchJson } from "../src/fetchJson"; @@ -29,7 +30,7 @@ function listenOnce(handler: RequestListener): Promise<{ }); } -test("parses JSON body against the schema", async function (t) { +test("parses JSON body against the schema", async function () { const { server, url } = await listenOnce(function (_req, res) { res.writeHead(200, { "Content-Type": "application/json" }); res.end(JSON.stringify({ ok: true, count: 7 })); @@ -42,20 +43,20 @@ test("parses JSON body against the schema", async function (t) { schema: Schema, }); - t.deepEqual(result, { ok: true, count: 7 }); + deepStrictEqual(result, { ok: true, count: 7 }); } finally { server.close(); } }); -test("non-2xx status throws HttpError", async function (t) { +test("non-2xx status throws HttpError", async function () { const { server, url } = await listenOnce(function (_req, res) { res.writeHead(404); res.end(); }); try { - await t.throwsAsync( + await rejects( async function () { return fetchJson({ url, @@ -63,7 +64,7 @@ test("non-2xx status throws HttpError", async function (t) { schema: Schema, }); }, - { instanceOf: HttpError }, + HttpError, ); } finally { server.close(); diff --git a/paddler_client_javascript/tests/schemas/Agent.test.ts b/paddler_client_javascript/tests/schemas/Agent.test.ts index fab857d7..a01ff9c3 100644 --- a/paddler_client_javascript/tests/schemas/Agent.test.ts +++ b/paddler_client_javascript/tests/schemas/Agent.test.ts @@ -1,8 +1,9 @@ -import test from "ava"; +import { strictEqual, throws } from "node:assert/strict"; +import { test } from "node:test"; import { AgentSchema } from "../../src/schemas/Agent"; -test("parses a fully populated agent payload", function (t) { +test("parses a fully populated agent payload", function () { const parsed = AgentSchema.parse({ desired_slots_total: 4, download_current: 0, @@ -18,12 +19,12 @@ test("parses a fully populated agent payload", function (t) { uses_chat_template_override: false, }); - t.is(parsed.id, "agent-0"); - t.is(parsed.state_application_status, "Applied"); + strictEqual(parsed.id, "agent-0"); + strictEqual(parsed.state_application_status, "Applied"); }); -test("rejects an unknown state_application_status", function (t) { - t.throws(function () { +test("rejects an unknown state_application_status", function () { + throws(function () { AgentSchema.parse({ desired_slots_total: 1, download_current: 0, diff --git a/paddler_client_javascript/tests/schemas/InferenceServiceGenerateTokensResponse.test.ts b/paddler_client_javascript/tests/schemas/InferenceServiceGenerateTokensResponse.test.ts index adde7b9a..b9536cb3 100644 --- a/paddler_client_javascript/tests/schemas/InferenceServiceGenerateTokensResponse.test.ts +++ b/paddler_client_javascript/tests/schemas/InferenceServiceGenerateTokensResponse.test.ts @@ -1,8 +1,14 @@ -import test from "ava"; +import { + deepStrictEqual, + notStrictEqual, + ok, + strictEqual, +} from "node:assert/strict"; +import { test } from "node:test"; import { InferenceServiceGenerateTokensResponseSchema } from "../../src/schemas/InferenceServiceGenerateTokensResponse"; -test("ContentToken normalises into a streaming token with content kind", function (t) { +test("ContentToken normalises into a streaming token with content kind", function () { const parsed = InferenceServiceGenerateTokensResponseSchema.parse({ Response: { generated_by: null, @@ -11,14 +17,14 @@ test("ContentToken normalises into a streaming token with content kind", functio }, }); - t.is(parsed.done, false); - t.is(parsed.error, null); - t.is(parsed.token, "Hello"); - t.is(parsed.tokenKind, "content"); - t.is(parsed.toolCalls, null); + strictEqual(parsed.done, false); + strictEqual(parsed.error, null); + strictEqual(parsed.token, "Hello"); + strictEqual(parsed.tokenKind, "content"); + strictEqual(parsed.toolCalls, null); }); -test("ReasoningToken maps to reasoning kind", function (t) { +test("ReasoningToken maps to reasoning kind", function () { const parsed = InferenceServiceGenerateTokensResponseSchema.parse({ Response: { generated_by: null, @@ -27,11 +33,11 @@ test("ReasoningToken maps to reasoning kind", function (t) { }, }); - t.is(parsed.token, "thinking..."); - t.is(parsed.tokenKind, "reasoning"); + strictEqual(parsed.token, "thinking..."); + strictEqual(parsed.tokenKind, "reasoning"); }); -test("Done normalises with the full usage summary", function (t) { +test("Done normalises with the full usage summary", function () { const parsed = InferenceServiceGenerateTokensResponseSchema.parse({ Response: { generated_by: null, @@ -55,12 +61,12 @@ test("Done normalises with the full usage summary", function (t) { }, }); - t.is(parsed.done, true); - t.is(parsed.error, null); - t.deepEqual(parsed.summary?.usage.prompt_tokens, 10); + strictEqual(parsed.done, true); + strictEqual(parsed.error, null); + deepStrictEqual(parsed.summary?.usage.prompt_tokens, 10); }); -test("ToolCallValidatorBuildFailed normalises to a terminal error", function (t) { +test("ToolCallValidatorBuildFailed normalises to a terminal error", function () { const parsed = InferenceServiceGenerateTokensResponseSchema.parse({ Response: { generated_by: null, @@ -73,11 +79,11 @@ test("ToolCallValidatorBuildFailed normalises to a terminal error", function (t) }, }); - t.is(parsed.done, true); - t.deepEqual(parsed.error, { code: 400, description: "schema invalid" }); + strictEqual(parsed.done, true); + deepStrictEqual(parsed.error, { code: 400, description: "schema invalid" }); }); -test("Top-level Error envelope normalises to terminal error", function (t) { +test("Top-level Error envelope normalises to terminal error", function () { const parsed = InferenceServiceGenerateTokensResponseSchema.parse({ Error: { request_id: "req-5", @@ -85,11 +91,11 @@ test("Top-level Error envelope normalises to terminal error", function (t) { }, }); - t.is(parsed.done, true); - t.deepEqual(parsed.error, { code: 500, description: "boom" }); + strictEqual(parsed.done, true); + deepStrictEqual(parsed.error, { code: 500, description: "boom" }); }); -test("UnrecognizedToolCallFormat preserves text and FFI error message", function (t) { +test("UnrecognizedToolCallFormat preserves text and FFI error message", function () { const parsed = InferenceServiceGenerateTokensResponseSchema.parse({ Response: { generated_by: null, @@ -105,19 +111,19 @@ test("UnrecognizedToolCallFormat preserves text and FFI error message", function }, }); - t.is(parsed.done, false); - t.is(parsed.error, null); - t.is(parsed.ok, true); - t.is(parsed.token, null); - t.is(parsed.tokenKind, null); - t.is(parsed.toolCalls, null); - t.deepEqual(parsed.rawToolCallTokens, { + strictEqual(parsed.done, false); + strictEqual(parsed.error, null); + strictEqual(parsed.ok, true); + strictEqual(parsed.token, null); + strictEqual(parsed.tokenKind, null); + strictEqual(parsed.toolCalls, null); + deepStrictEqual(parsed.rawToolCallTokens, { text: "raw", ffi_error_message: "common_chat_parse failed: no parser", }); }); -test("ImageExceedsBatchSize is terminal and describes token counts", function (t) { +test("ImageExceedsBatchSize is terminal and describes token counts", function () { const parsed = InferenceServiceGenerateTokensResponseSchema.parse({ Response: { generated_by: null, @@ -130,10 +136,10 @@ test("ImageExceedsBatchSize is terminal and describes token counts", function (t }, }); - t.is(parsed.done, true); - t.is(parsed.ok, false); - t.not(parsed.error, null); - t.is(parsed.error?.code, 400); - t.true(parsed.error?.description.includes("368")); - t.true(parsed.error?.description.includes("100")); + strictEqual(parsed.done, true); + strictEqual(parsed.ok, false); + notStrictEqual(parsed.error, null); + strictEqual(parsed.error?.code, 400); + ok(parsed.error?.description.includes("368")); + ok(parsed.error?.description.includes("100")); }); diff --git a/paddler_client_javascript/tests/schemas/ParsedToolCall.test.ts b/paddler_client_javascript/tests/schemas/ParsedToolCall.test.ts index 534634f0..b43ecebb 100644 --- a/paddler_client_javascript/tests/schemas/ParsedToolCall.test.ts +++ b/paddler_client_javascript/tests/schemas/ParsedToolCall.test.ts @@ -1,31 +1,32 @@ -import test from "ava"; +import { deepStrictEqual, strictEqual, throws } from "node:assert/strict"; +import { test } from "node:test"; import { ParsedToolCallSchema } from "../../src/schemas/ParsedToolCall"; -test("ValidJson arguments parse with the inner JSON kept intact", function (t) { +test("ValidJson arguments parse with the inner JSON kept intact", function () { const parsed = ParsedToolCallSchema.parse({ id: "call_0", name: "get_weather", arguments: { ValidJson: { location: "Paris" } }, }); - t.is(parsed.id, "call_0"); - t.is(parsed.name, "get_weather"); - t.deepEqual(parsed.arguments, { ValidJson: { location: "Paris" } }); + strictEqual(parsed.id, "call_0"); + strictEqual(parsed.name, "get_weather"); + deepStrictEqual(parsed.arguments, { ValidJson: { location: "Paris" } }); }); -test("InvalidJson arguments preserve the raw string", function (t) { +test("InvalidJson arguments preserve the raw string", function () { const parsed = ParsedToolCallSchema.parse({ id: "call_1", name: "get_weather", arguments: { InvalidJson: "not json" }, }); - t.deepEqual(parsed.arguments, { InvalidJson: "not json" }); + deepStrictEqual(parsed.arguments, { InvalidJson: "not json" }); }); -test("rejects payloads missing the discriminated arguments wrapper", function (t) { - t.throws(function () { +test("rejects payloads missing the discriminated arguments wrapper", function () { + throws(function () { ParsedToolCallSchema.parse({ id: "call_2", name: "get_weather", diff --git a/paddler_client_javascript/tests/streamHttpNdjson.test.ts b/paddler_client_javascript/tests/streamHttpNdjson.test.ts index a3fc829b..677a79d6 100644 --- a/paddler_client_javascript/tests/streamHttpNdjson.test.ts +++ b/paddler_client_javascript/tests/streamHttpNdjson.test.ts @@ -1,5 +1,6 @@ -import test from "ava"; +import { deepStrictEqual, rejects } from "node:assert/strict"; import { createServer, type RequestListener, type Server } from "node:http"; +import { test } from "node:test"; import { firstValueFrom, lastValueFrom, toArray } from "rxjs"; import { z } from "zod"; @@ -30,7 +31,7 @@ function listenOnce(handler: RequestListener): Promise<{ }); } -test("yields parsed messages from an NDJSON stream", async function (t) { +test("yields parsed messages from an NDJSON stream", async function () { const { server, url } = await listenOnce(function (_req, res) { res.writeHead(200, { "Content-Type": "application/x-ndjson" }); res.write(`${JSON.stringify({ index: 0 })}\n`); @@ -49,7 +50,7 @@ test("yields parsed messages from an NDJSON stream", async function (t) { }).pipe(toArray()), ); - t.deepEqual(messages, [ + deepStrictEqual(messages, [ { index: 0 }, { index: 1 }, { index: 2 }, @@ -59,14 +60,14 @@ test("yields parsed messages from an NDJSON stream", async function (t) { } }); -test("non-2xx response throws HttpError", async function (t) { +test("non-2xx response throws HttpError", async function () { const { server, url } = await listenOnce(function (_req, res) { res.writeHead(503); res.end(); }); try { - await t.throwsAsync( + await rejects( async function () { await firstValueFrom( streamHttpNdjson({ @@ -77,7 +78,7 @@ test("non-2xx response throws HttpError", async function (t) { }), ); }, - { instanceOf: HttpError }, + HttpError, ); } finally { server.close(); diff --git a/paddler_client_javascript/tests/urlToAgentDesiredModel.test.ts b/paddler_client_javascript/tests/urlToAgentDesiredModel.test.ts index ce273a9a..06e6b517 100644 --- a/paddler_client_javascript/tests/urlToAgentDesiredModel.test.ts +++ b/paddler_client_javascript/tests/urlToAgentDesiredModel.test.ts @@ -1,13 +1,14 @@ -import test from "ava"; +import { deepStrictEqual, throws } from "node:assert/strict"; +import { test } from "node:test"; import { urlToAgentDesiredModel } from "../src/urlToAgentDesiredModel"; -test("recognizes Hugging Face URLs as HuggingFace variant", function (t) { +test("recognizes Hugging Face URLs as HuggingFace variant", function () { const url = new URL( "https://huggingface.co/Qwen/Qwen3-0.6B-GGUF/blob/main/Qwen3-0.6B-Q8_0.gguf", ); - t.deepEqual(urlToAgentDesiredModel(url), { + deepStrictEqual(urlToAgentDesiredModel(url), { HuggingFace: { filename: "Qwen3-0.6B-Q8_0.gguf", repo_id: "Qwen/Qwen3-0.6B-GGUF", @@ -16,18 +17,18 @@ test("recognizes Hugging Face URLs as HuggingFace variant", function (t) { }); }); -test("agent: URLs become LocalToAgent variant", function (t) { +test("agent: URLs become LocalToAgent variant", function () { const url = new URL("agent:///home/user/models/Qwen3-0.6B-Q8_0.gguf"); - t.deepEqual(urlToAgentDesiredModel(url), { + deepStrictEqual(urlToAgentDesiredModel(url), { LocalToAgent: "/home/user/models/Qwen3-0.6B-Q8_0.gguf", }); }); -test("unsupported URLs throw", function (t) { +test("unsupported URLs throw", function () { const url = new URL("https://example.com/some/path"); - t.throws( + throws( function () { urlToAgentDesiredModel(url); }, diff --git a/paddler_client_javascript/tests/webSocketProtocol.test.ts b/paddler_client_javascript/tests/webSocketProtocol.test.ts index a80971e7..73d4e8e1 100644 --- a/paddler_client_javascript/tests/webSocketProtocol.test.ts +++ b/paddler_client_javascript/tests/webSocketProtocol.test.ts @@ -1,15 +1,16 @@ -import test from "ava"; +import { strictEqual } from "node:assert/strict"; +import { test } from "node:test"; import { webSocketProtocol } from "../src/webSocketProtocol"; -test("https: maps to wss:", function (t) { - t.is(webSocketProtocol("https:"), "wss:"); +test("https: maps to wss:", function () { + strictEqual(webSocketProtocol("https:"), "wss:"); }); -test("http: maps to ws:", function (t) { - t.is(webSocketProtocol("http:"), "ws:"); +test("http: maps to ws:", function () { + strictEqual(webSocketProtocol("http:"), "ws:"); }); -test("anything other than https: maps to ws:", function (t) { - t.is(webSocketProtocol("file:"), "ws:"); +test("anything other than https: maps to ws:", function () { + strictEqual(webSocketProtocol("file:"), "ws:"); }); diff --git a/paddler_client_javascript/tsconfig.test.json b/paddler_client_javascript/tsconfig.test.json new file mode 100644 index 00000000..908772ec --- /dev/null +++ b/paddler_client_javascript/tsconfig.test.json @@ -0,0 +1,8 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "noEmit": true, + "rootDir": "." + }, + "include": ["src/**/*", "tests/**/*"] +} From c38e57bf49dc9a811409621a549529ff8fffa9ad Mon Sep 17 00:00:00 2001 From: Mateusz Charytoniuk Date: Wed, 13 May 2026 03:14:28 +0200 Subject: [PATCH 44/51] Share cargo target dir between integration build and tests so CUDA kernels are built once --- Makefile | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Makefile b/Makefile index 2308cd7d..1f90ea08 100644 --- a/Makefile +++ b/Makefile @@ -72,11 +72,11 @@ test.integration: target/debug/paddler .PHONY: test.integration.cuda test.integration.cuda: target/cuda/debug/paddler - PADDLER_BINARY_PATH=../target/cuda/debug/paddler PADDLER_TEST_DEVICE=cuda cargo test -p paddler_tests --features cuda,tests_that_use_compiled_paddler,tests_that_use_llms + PADDLER_BINARY_PATH=../target/cuda/debug/paddler PADDLER_TEST_DEVICE=cuda cargo test --target-dir target/cuda -p paddler_tests --features cuda,tests_that_use_compiled_paddler,tests_that_use_llms .PHONY: test.integration.metal test.integration.metal: target/metal/debug/paddler - PADDLER_BINARY_PATH=../target/metal/debug/paddler PADDLER_TEST_DEVICE=metal cargo test -p paddler_tests --features metal,tests_that_use_compiled_paddler,tests_that_use_llms + PADDLER_BINARY_PATH=../target/metal/debug/paddler PADDLER_TEST_DEVICE=metal cargo test --target-dir target/metal -p paddler_tests --features metal,tests_that_use_compiled_paddler,tests_that_use_llms .PHONY: test.unit test.unit: esbuild-meta.json From a1fb73b784e150115e64482c1b516d4caf7333b5 Mon Sep 17 00:00:00 2001 From: Mateusz Charytoniuk Date: Wed, 13 May 2026 03:14:28 +0200 Subject: [PATCH 45/51] Detect agent disappearance in AgentsStreamWatcher and fail fast instead of hanging --- paddler_tests/src/agents_stream_watcher.rs | 170 ++++++++++++++++++ ..._find_chat_template_for_embedding_model.rs | 5 +- 2 files changed, 173 insertions(+), 2 deletions(-) diff --git a/paddler_tests/src/agents_stream_watcher.rs b/paddler_tests/src/agents_stream_watcher.rs index 5d33dd95..ad5dd624 100644 --- a/paddler_tests/src/agents_stream_watcher.rs +++ b/paddler_tests/src/agents_stream_watcher.rs @@ -55,6 +55,38 @@ impl AgentsStreamWatcher { )) } + pub async fn until_agent( + &mut self, + agent_id: &str, + mut predicate: TPredicate, + ) -> Result + where + TPredicate: FnMut(&AgentControllerPoolSnapshot) -> bool, + { + while let Some(item) = self.stream.next().await { + let snapshot = item.context("agents stream yielded an error")?; + + let agent_present = snapshot + .agents + .iter() + .any(|registered_agent| registered_agent.id == agent_id); + + if !agent_present { + bail!( + "agent {agent_id} disappeared from the balancer's agent pool before the predicate was satisfied; this means the agent subprocess died or its WebSocket dropped" + ); + } + + if predicate(&snapshot) { + return Ok(snapshot); + } + } + + Err(anyhow!( + "agents stream closed before predicate was satisfied" + )) + } + pub async fn wait_for_slots_ready(&mut self, expected_slot_counts: &[i32]) -> Result<()> { let mut expected_sorted: Vec = expected_slot_counts.to_vec(); expected_sorted.sort_unstable(); @@ -101,3 +133,141 @@ impl AgentsStreamWatcher { Ok(()) } } + +#[cfg(test)] +mod tests { + use std::collections::BTreeSet; + + use futures_util::stream; + use paddler_types::agent_controller_snapshot::AgentControllerSnapshot; + use paddler_types::agent_issue::AgentIssue; + use paddler_types::agent_issue_params::ModelPath; + use paddler_types::agent_state_application_status::AgentStateApplicationStatus; + + use super::*; + + fn snapshot_with_agent( + agent_id: &str, + issues: BTreeSet, + ) -> AgentControllerSnapshot { + AgentControllerSnapshot { + desired_slots_total: 1, + download_current: 0, + download_filename: None, + download_total: 0, + id: agent_id.to_owned(), + issues, + model_path: None, + name: Some(agent_id.to_owned()), + slots_processing: 0, + slots_total: 0, + state_application_status: AgentStateApplicationStatus::Fresh, + uses_chat_template_override: false, + } + } + + fn unable_to_find_chat_template_issue() -> BTreeSet { + let mut issues = BTreeSet::new(); + issues.insert(AgentIssue::UnableToFindChatTemplate(ModelPath { + model_path: "/models/embed.gguf".to_owned(), + })); + issues + } + + fn make_watcher(snapshots: Vec) -> AgentsStreamWatcher { + AgentsStreamWatcher::from_stream(Box::pin(stream::iter(snapshots.into_iter().map(Ok)))) + } + + #[tokio::test] + async fn until_agent_returns_ok_when_predicate_matches_with_agent_present() -> Result<()> { + let agent_id = "agent-x"; + let snapshots = vec![ + AgentControllerPoolSnapshot { + agents: vec![snapshot_with_agent(agent_id, BTreeSet::new())], + }, + AgentControllerPoolSnapshot { + agents: vec![snapshot_with_agent( + agent_id, + unable_to_find_chat_template_issue(), + )], + }, + ]; + + let mut watcher = make_watcher(snapshots); + + let predicate_agent_id = agent_id.to_owned(); + let observed = watcher + .until_agent(agent_id, move |snapshot| { + snapshot.agents.iter().any(|agent| { + agent.id == predicate_agent_id + && agent + .issues + .iter() + .any(|issue| matches!(issue, AgentIssue::UnableToFindChatTemplate(_))) + }) + }) + .await?; + + assert!( + observed.agents.iter().any(|agent| agent.id == agent_id), + "matched snapshot must contain the watched agent" + ); + + Ok(()) + } + + #[tokio::test] + async fn until_agent_errors_when_agent_disappears_mid_stream() -> Result<()> { + let agent_id = "agent-y"; + let snapshots = vec![ + AgentControllerPoolSnapshot { + agents: vec![snapshot_with_agent(agent_id, BTreeSet::new())], + }, + AgentControllerPoolSnapshot { agents: vec![] }, + ]; + + let mut watcher = make_watcher(snapshots); + + let error = watcher + .until_agent(agent_id, |_snapshot| false) + .await + .err() + .context("until_agent must surface the disappearance as an error")?; + let rendered = format!("{error:#}"); + + assert!( + rendered.contains("disappeared"), + "error must explicitly call out the disappearance, got: {rendered}" + ); + assert!( + rendered.contains(agent_id), + "error must name the missing agent, got: {rendered}" + ); + + Ok(()) + } + + #[tokio::test] + async fn until_agent_errors_when_stream_closes_before_predicate_matches() -> Result<()> { + let agent_id = "agent-z"; + let snapshots = vec![AgentControllerPoolSnapshot { + agents: vec![snapshot_with_agent(agent_id, BTreeSet::new())], + }]; + + let mut watcher = make_watcher(snapshots); + + let error = watcher + .until_agent(agent_id, |_snapshot| false) + .await + .err() + .context("until_agent must error when the stream ends without a match")?; + let rendered = format!("{error:#}"); + + assert!( + rendered.contains("stream closed"), + "error must surface the stream-closed condition, got: {rendered}" + ); + + Ok(()) + } +} diff --git a/paddler_tests/tests/balancer_reports_unable_to_find_chat_template_for_embedding_model.rs b/paddler_tests/tests/balancer_reports_unable_to_find_chat_template_for_embedding_model.rs index bbabf31f..35a5c442 100644 --- a/paddler_tests/tests/balancer_reports_unable_to_find_chat_template_for_embedding_model.rs +++ b/paddler_tests/tests/balancer_reports_unable_to_find_chat_template_for_embedding_model.rs @@ -40,11 +40,12 @@ async fn balancer_reports_unable_to_find_chat_template_for_embedding_model() -> .context("cluster must have one registered agent")? .clone(); + let predicate_agent_id = agent_id.clone(); cluster .agents - .until(move |snapshot| { + .until_agent(&agent_id, move |snapshot| { snapshot.agents.iter().any(|agent| { - agent.id == agent_id + agent.id == predicate_agent_id && agent .issues .iter() From 4d49f780436a4d8b64618ee14c1e7314774f04c3 Mon Sep 17 00:00:00 2001 From: Mateusz Charytoniuk Date: Wed, 13 May 2026 15:39:13 +0200 Subject: [PATCH 46/51] update claude rules --- .claude/rules/rust-integration-tests.md | 13 +++++++++++++ .claude/rules/rust.md | 13 +++++++++++++ 2 files changed, 26 insertions(+) create mode 100644 .claude/rules/rust-integration-tests.md diff --git a/.claude/rules/rust-integration-tests.md b/.claude/rules/rust-integration-tests.md new file mode 100644 index 00000000..5e0004ba --- /dev/null +++ b/.claude/rules/rust-integration-tests.md @@ -0,0 +1,13 @@ +--- +paths: + - "**/tests/**/*.rs" +--- + +# Rust Integration Tests Standards + +- Each test needs to be named after what functionality, or issue it actually tests. +- Each test file needs to be named after what functionality, or issue it actually tests. +- Each test represents a specific scenario that the core project needs to support, or represent an uncovered issue. +- If you uncover a new issue while testing, create yet another targeted test that covers that. +- Every test muse use production code. Never recreate the original code to test something conceptually. Always use production code. +- They must be single-purpose. diff --git a/.claude/rules/rust.md b/.claude/rules/rust.md index 3d940a23..4584b330 100644 --- a/.claude/rules/rust.md +++ b/.claude/rules/rust.md @@ -21,3 +21,16 @@ paths: - Never use Result<> as a function argument. - Never forward Result in enums if you can instead create a targeted error enum. It is always better to signal the specific issue, so it can be handled downstream. - Always destructure structs in arguments if possible. + +# Code Style + +Imports/uses must not be mixed with other kinds of rust syntax. + +Each file needs to follow this order: +1. `pub mod`/`mod` exports +2. vendor crate `use` +2. project crate `use` +3. local crate `use` +4. private function helpers +5. private struct helpers +6. single public export From 35187626fb86d3ac56d7a044308ddb896641a146 Mon Sep 17 00:00:00 2001 From: Mateusz Charytoniuk Date: Wed, 13 May 2026 17:57:16 +0200 Subject: [PATCH 47/51] update claude rules --- .claude/rules/rust-integration-tests.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.claude/rules/rust-integration-tests.md b/.claude/rules/rust-integration-tests.md index 5e0004ba..53abd1c1 100644 --- a/.claude/rules/rust-integration-tests.md +++ b/.claude/rules/rust-integration-tests.md @@ -11,3 +11,5 @@ paths: - If you uncover a new issue while testing, create yet another targeted test that covers that. - Every test muse use production code. Never recreate the original code to test something conceptually. Always use production code. - They must be single-purpose. +- It must be clear what is being tested in the test file by just reading the filename. + From 788e5a8ddcc21a116c3c9f554d85fe61649c2060 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ma=C5=82gorzata=20Zagajewska?= Date: Wed, 13 May 2026 18:00:46 +0200 Subject: [PATCH 48/51] Forward 502 envelope when agent response channel closes before terminator --- .../identity_transformer.rs | 3 +- paddler/src/balancer/mod.rs | 8 ++-- paddler/src/balancer/request_from_agent.rs | 47 ++++++++++++------- 3 files changed, 36 insertions(+), 22 deletions(-) diff --git a/paddler/src/balancer/chunk_forwarding_session_controller/identity_transformer.rs b/paddler/src/balancer/chunk_forwarding_session_controller/identity_transformer.rs index 8731595b..f9d964af 100644 --- a/paddler/src/balancer/chunk_forwarding_session_controller/identity_transformer.rs +++ b/paddler/src/balancer/chunk_forwarding_session_controller/identity_transformer.rs @@ -5,10 +5,11 @@ use paddler_types::inference_client::Message as OutgoingMessage; use super::transform_result::TransformResult; use super::transforms_outgoing_message::TransformsOutgoingMessage; -#[derive(Clone)] +#[derive(Clone, Default)] pub struct IdentityTransformer; impl IdentityTransformer { + #[must_use] pub const fn new() -> Self { Self {} } diff --git a/paddler/src/balancer/mod.rs b/paddler/src/balancer/mod.rs index 140ae13e..f0011cbf 100644 --- a/paddler/src/balancer/mod.rs +++ b/paddler/src/balancer/mod.rs @@ -8,7 +8,7 @@ mod buffered_request_count_guard; mod buffered_request_counter; pub mod buffered_request_manager; pub mod chat_template_override_sender_collection; -mod chunk_forwarding_session_controller; +pub mod chunk_forwarding_session_controller; pub mod compatibility; mod controls_manages_senders_endpoint; pub mod dispatch_candidate; @@ -20,11 +20,11 @@ mod http_route; mod http_stream_from_agent; pub mod inference_service; pub mod management_service; -mod manages_senders; -mod manages_senders_controller; +pub mod manages_senders; +pub mod manages_senders_controller; pub mod model_metadata_sender_collection; pub mod reconciliation_service; -mod request_from_agent; +pub mod request_from_agent; #[cfg(feature = "web_admin_panel")] mod response; pub mod state_database; diff --git a/paddler/src/balancer/request_from_agent.rs b/paddler/src/balancer/request_from_agent.rs index 346cb0b1..d66bad35 100644 --- a/paddler/src/balancer/request_from_agent.rs +++ b/paddler/src/balancer/request_from_agent.rs @@ -87,7 +87,7 @@ where } } -async fn forward_responses_stream( +pub async fn forward_responses_stream( agent_controller: Arc, connection_close: CancellationToken, inference_service_configuration: InferenceServiceConfiguration, @@ -154,22 +154,35 @@ where break; } response = receive_response_controller.response_rx.recv() => { - match response { - Some(response) => { - let is_done = response.is_done(); - - let send_succeeded = send_response_to_client( - agent_controller.clone(), - response, - request_id.clone(), - &mut session_controller, - ).await; - - if is_done || !send_succeeded { - break; - } + if let Some(response) = response { + let is_done = response.is_done(); + + let send_succeeded = send_response_to_client( + agent_controller.clone(), + response, + request_id.clone(), + &mut session_controller, + ).await; + + if is_done || !send_succeeded { + break; } - None => break, + } else { + error!( + "Response channel closed before terminator for request {request_id:?}" + ); + + respond_with_error( + JsonRpcError { + code: 502, + description: + "Response channel closed before terminator".to_owned(), + }, + request_id, + &mut session_controller, + ).await; + + break; } } } @@ -178,7 +191,7 @@ where Ok(()) } -async fn respond_with_error( +pub async fn respond_with_error( error: JsonRpcError, request_id: String, session_controller: &mut TControlsSession, From 7c22e8d04bbe14bc0bed50ef677fac366a942fb1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ma=C5=82gorzata=20Zagajewska?= Date: Wed, 13 May 2026 18:00:53 +0200 Subject: [PATCH 49/51] Add typed EmbeddingResult variants for empty-batch and reject-during-token-generation --- .../continuous_batch_embedding_processor.rs | 10 ++++++- .../agent/continuous_batch_scheduler/mod.rs | 5 +--- .../paddler_client/inference_message.py | 18 ++++++++++++ .../tests/test_inference_message.py | 29 +++++++++++++++++++ .../src/collect_embedding_results.rs | 12 ++++++++ .../src/collected_embedding_results.rs | 2 ++ paddler_types/src/embedding_result.rs | 21 +++++++++++++- 7 files changed, 91 insertions(+), 6 deletions(-) diff --git a/paddler/src/agent/continuous_batch_embedding_processor.rs b/paddler/src/agent/continuous_batch_embedding_processor.rs index a74dd625..9952047d 100644 --- a/paddler/src/agent/continuous_batch_embedding_processor.rs +++ b/paddler/src/agent/continuous_batch_embedding_processor.rs @@ -120,6 +120,8 @@ impl<'context> ContinuousBatchEmbeddingProcessor<'context> { plan_embedding_batches(&token_counts, n_batch, max_sequences_per_batch); let mut batch = LlamaBatch::new(n_batch, max_sequences_per_batch)?; + let mut embeddings_emitted: usize = 0; + #[expect( clippy::cast_possible_truncation, clippy::cast_possible_wrap, @@ -145,9 +147,15 @@ impl<'context> ContinuousBatchEmbeddingProcessor<'context> { &generated_embedding_tx, &normalization_method, )?; + + embeddings_emitted += batch_inputs.len(); } - generated_embedding_tx.send(EmbeddingResult::Done)?; + if embeddings_emitted == 0 { + generated_embedding_tx.send(EmbeddingResult::NoEmbeddingsProduced)?; + } else { + generated_embedding_tx.send(EmbeddingResult::Done)?; + } Ok(()) } diff --git a/paddler/src/agent/continuous_batch_scheduler/mod.rs b/paddler/src/agent/continuous_batch_scheduler/mod.rs index 7593a94c..a21b5f2e 100644 --- a/paddler/src/agent/continuous_batch_scheduler/mod.rs +++ b/paddler/src/agent/continuous_batch_scheduler/mod.rs @@ -930,10 +930,7 @@ impl ContinuousBatchScheduler { if self.has_active_requests() { if request .generated_embedding_tx - .send(EmbeddingResult::Error( - "Embedding requests cannot be processed while generation requests are active" - .to_owned(), - )) + .send(EmbeddingResult::EmbeddingRejectedDueToActiveTokenGeneration) .is_err() { warn!( diff --git a/paddler_client_python/paddler_client/inference_message.py b/paddler_client_python/paddler_client/inference_message.py index a6db786d..c8e63f1e 100644 --- a/paddler_client_python/paddler_client/inference_message.py +++ b/paddler_client_python/paddler_client/inference_message.py @@ -19,6 +19,10 @@ class InferenceMessageKind(StrEnum): EMBEDDING = "embedding" EMBEDDING_DONE = "embedding_done" EMBEDDING_ERROR = "embedding_error" + EMBEDDING_REJECTED_DUE_TO_ACTIVE_TOKEN_GENERATION = ( + "embedding_rejected_due_to_active_token_generation" + ) + EMBEDDING_NO_EMBEDDINGS_PRODUCED = "embedding_no_embeddings_produced" GRAMMAR_INCOMPATIBLE_WITH_THINKING = "grammar_incompatible_with_thinking" GRAMMAR_INITIALIZATION_FAILED = "grammar_initialization_failed" GRAMMAR_REJECTED_MODEL_OUTPUT = "grammar_rejected_model_output" @@ -410,6 +414,20 @@ def _parse_embedding_result( generated_by=generated_by, ) + if data == "EmbeddingRejectedDueToActiveTokenGeneration": + return InferenceMessage( + request_id=request_id, + kind=InferenceMessageKind.EMBEDDING_REJECTED_DUE_TO_ACTIVE_TOKEN_GENERATION, + generated_by=generated_by, + ) + + if data == "NoEmbeddingsProduced": + return InferenceMessage( + request_id=request_id, + kind=InferenceMessageKind.EMBEDDING_NO_EMBEDDINGS_PRODUCED, + generated_by=generated_by, + ) + if isinstance(data, dict): if "Embedding" in data: embedding = Embedding.model_validate(data["Embedding"]) diff --git a/paddler_client_python/tests/test_inference_message.py b/paddler_client_python/tests/test_inference_message.py index f971bb3d..4f053da1 100644 --- a/paddler_client_python/tests/test_inference_message.py +++ b/paddler_client_python/tests/test_inference_message.py @@ -506,6 +506,35 @@ def test_parse_embedding_error() -> None: assert message.is_terminal +def test_parse_embedding_no_embeddings_produced() -> None: + data = { + "Response": { + "request_id": "req-1", + "response": {"Embedding": "NoEmbeddingsProduced"}, + } + } + message = parse_inference_client_message(data) + + assert message.kind == InferenceMessageKind.EMBEDDING_NO_EMBEDDINGS_PRODUCED + assert message.is_terminal + + +def test_parse_embedding_rejected_due_to_active_token_generation() -> None: + data = { + "Response": { + "request_id": "req-1", + "response": {"Embedding": "EmbeddingRejectedDueToActiveTokenGeneration"}, + } + } + message = parse_inference_client_message(data) + + assert ( + message.kind + == InferenceMessageKind.EMBEDDING_REJECTED_DUE_TO_ACTIVE_TOKEN_GENERATION + ) + assert message.is_terminal + + def test_parse_json_string() -> None: json_str = ( '{"Response": {"request_id": "req-1", "response": {"GeneratedToken": ' diff --git a/paddler_tests/src/collect_embedding_results.rs b/paddler_tests/src/collect_embedding_results.rs index 8c311940..0b846d05 100644 --- a/paddler_tests/src/collect_embedding_results.rs +++ b/paddler_tests/src/collect_embedding_results.rs @@ -16,6 +16,8 @@ pub async fn collect_embedding_results( let mut embeddings: Vec = Vec::new(); let mut embeddings_disabled = false; let mut errors: Vec = Vec::new(); + let mut embedding_rejected_due_to_active_token_generation_count: usize = 0; + let mut no_embeddings_produced_count: usize = 0; let mut oversized_documents = Vec::new(); let mut saw_done = false; let mut wire_errors = Vec::new(); @@ -50,6 +52,14 @@ pub async fn collect_embedding_results( InferenceResponse::Embedding(EmbeddingResult::Error(message)) => { errors.push(message); } + InferenceResponse::Embedding( + EmbeddingResult::EmbeddingRejectedDueToActiveTokenGeneration, + ) => { + embedding_rejected_due_to_active_token_generation_count += 1; + } + InferenceResponse::Embedding(EmbeddingResult::NoEmbeddingsProduced) => { + no_embeddings_produced_count += 1; + } InferenceResponse::GeneratedToken(_) => { return Err(anyhow!( "unexpected generated-token response on an embedding stream" @@ -75,6 +85,8 @@ pub async fn collect_embedding_results( embeddings, embeddings_disabled, errors, + embedding_rejected_due_to_active_token_generation_count, + no_embeddings_produced_count, oversized_documents, saw_done, wire_errors, diff --git a/paddler_tests/src/collected_embedding_results.rs b/paddler_tests/src/collected_embedding_results.rs index 528d6c1b..4d785d94 100644 --- a/paddler_tests/src/collected_embedding_results.rs +++ b/paddler_tests/src/collected_embedding_results.rs @@ -7,6 +7,8 @@ pub struct CollectedEmbeddingResults { pub embeddings: Vec, pub embeddings_disabled: bool, pub errors: Vec, + pub embedding_rejected_due_to_active_token_generation_count: usize, + pub no_embeddings_produced_count: usize, pub oversized_documents: Vec, pub saw_done: bool, pub wire_errors: Vec, diff --git a/paddler_types/src/embedding_result.rs b/paddler_types/src/embedding_result.rs index 8804a21e..caf1a37a 100644 --- a/paddler_types/src/embedding_result.rs +++ b/paddler_types/src/embedding_result.rs @@ -13,11 +13,20 @@ pub enum EmbeddingResult { Embedding(Embedding), EmbeddingsDisabled, Error(String), + EmbeddingRejectedDueToActiveTokenGeneration, + NoEmbeddingsProduced, } impl StreamableResult for EmbeddingResult { fn is_done(&self) -> bool { - matches!(self, Self::Done | Self::EmbeddingsDisabled | Self::Error(_),) + matches!( + self, + Self::Done + | Self::EmbeddingsDisabled + | Self::Error(_) + | Self::EmbeddingRejectedDueToActiveTokenGeneration + | Self::NoEmbeddingsProduced, + ) } } @@ -42,6 +51,16 @@ mod tests { assert!(EmbeddingResult::EmbeddingsDisabled.is_done()); } + #[test] + fn embedding_rejected_due_to_active_token_generation_is_done() { + assert!(EmbeddingResult::EmbeddingRejectedDueToActiveTokenGeneration.is_done()); + } + + #[test] + fn no_embeddings_produced_is_done() { + assert!(EmbeddingResult::NoEmbeddingsProduced.is_done()); + } + #[test] fn document_exceeds_batch_size_is_not_done() { let result = EmbeddingResult::DocumentExceedsBatchSize(OversizedEmbeddingDocumentDetails { From 7cb196ea347bbed792385f2b4720456a0d1341a5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ma=C5=82gorzata=20Zagajewska?= Date: Wed, 13 May 2026 18:00:59 +0200 Subject: [PATCH 50/51] Synchronize warmup decode and spawn agents sequentially per name to stabilize Metal startup --- paddler/src/agent/continuous_batch_arbiter.rs | 116 +++++++++++----- paddler_tests/src/agents_stream_watcher.rs | 126 +++++++++++++++++- paddler_tests/src/start_subprocess_cluster.rs | 29 ++-- ...subprocess_cluster_with_qwen3_embedding.rs | 19 ++- .../src/subprocess_cluster_params.rs | 2 +- 5 files changed, 246 insertions(+), 46 deletions(-) diff --git a/paddler/src/agent/continuous_batch_arbiter.rs b/paddler/src/agent/continuous_batch_arbiter.rs index e4410241..d662d0ec 100644 --- a/paddler/src/agent/continuous_batch_arbiter.rs +++ b/paddler/src/agent/continuous_batch_arbiter.rs @@ -12,6 +12,7 @@ use llama_cpp_bindings::SampledToken; use llama_cpp_bindings::context::LlamaContext; use llama_cpp_bindings::context::params::LlamaContextParams; use llama_cpp_bindings::llama_backend::LlamaBackend; +use llama_cpp_bindings::llama_batch::LlamaBatch; use llama_cpp_bindings::model::LlamaModel; use llama_cpp_bindings::model::params::LlamaModelParams; use llama_cpp_bindings::mtmd::MtmdContext; @@ -19,6 +20,7 @@ use llama_cpp_bindings::mtmd::MtmdContextParams; use llama_cpp_bindings_sys::LLAMA_FLASH_ATTN_TYPE_AUTO; use log::error; use log::info; +use log::warn; use paddler_types::agent_issue::AgentIssue; use paddler_types::agent_issue_params::ChatTemplateDoesNotCompileParams; use paddler_types::agent_issue_params::ModelPath; @@ -83,6 +85,8 @@ impl ContinuousBatchArbiter { pub async fn spawn(&self) -> Result { let (chat_template_loaded_tx, chat_template_loaded_rx) = oneshot::channel::<()>(); let (model_loaded_tx, model_loaded_rx) = oneshot::channel::<()>(); + let (agent_warm_and_scheduler_running_tx, agent_warm_and_scheduler_running_rx) = + oneshot::channel::<()>(); let available_parallelism_value: i32 = available_parallelism()?.get().try_into()?; let n_threads = max(2, available_parallelism_value / 2); @@ -313,38 +317,37 @@ impl ContinuousBatchArbiter { model: model.clone(), }); - let llama_context = match LlamaContext::from_model(&model, &llama_backend, context_params) - .context("Unable to create llama.cpp context") - { - Ok(context) => context, - Err(err) => { - for slot_index in 0..desired_slots_total { - #[expect( - clippy::cast_sign_loss, - reason = "slot_index is always non-negative" - )] - slot_aggregated_status_manager - .slot_aggregated_status - .register_issue(AgentIssue::SlotCannotStart(SlotCannotStartParams { - error: format!("{err:#}"), - slot_index: slot_index as u32, - })); - } - - return Err(err); - } - }; + let mut llama_context = + match LlamaContext::from_model(&model, &llama_backend, context_params) + .context("Unable to create llama.cpp context") + { + Ok(context) => context, + Err(err) => { + for slot_index in 0..desired_slots_total { + #[expect( + clippy::cast_sign_loss, + reason = "slot_index is always non-negative" + )] + slot_aggregated_status_manager + .slot_aggregated_status + .register_issue(AgentIssue::SlotCannotStart( + SlotCannotStartParams { + error: format!("{err:#}"), + slot_index: slot_index as u32, + }, + )); + } - for slot_index in 0..desired_slots_total { - slot_aggregated_status_manager - .slot_aggregated_status - .increment_total_slots(); + return Err(err); + } + }; - #[expect(clippy::cast_sign_loss, reason = "slot_index is always non-negative")] - slot_aggregated_status_manager - .slot_aggregated_status - .register_fix(&AgentIssueFix::SlotStarted(slot_index as u32)); - } + Self::run_warmup_decode( + &model, + &mut llama_context, + scheduler_context.inference_parameters.n_batch, + desired_slots_total, + ); let mut scheduler = ContinuousBatchScheduler::new( command_rx, @@ -353,6 +356,14 @@ impl ContinuousBatchArbiter { desired_slots_total, ); + if agent_warm_and_scheduler_running_tx.send(()).is_err() { + let message = "Arbiter dropped the agent-warm-and-scheduler-running receiver before the scheduler could start"; + + error!("{message}"); + + return Err(anyhow!(message)); + } + scheduler.run(); Ok(()) @@ -410,9 +421,54 @@ impl ContinuousBatchArbiter { } } + agent_warm_and_scheduler_running_rx.await.context( + "Scheduler thread did not signal agent-warm-and-scheduler-running before exiting", + )?; + + for slot_index in 0..self.desired_slots_total { + self.slot_aggregated_status_manager + .slot_aggregated_status + .increment_total_slots(); + + #[expect(clippy::cast_sign_loss, reason = "slot_index is always non-negative")] + self.slot_aggregated_status_manager + .slot_aggregated_status + .register_fix(&AgentIssueFix::SlotStarted(slot_index as u32)); + } + Ok(ContinuousBatchArbiterHandle { command_tx, scheduler_thread_handle, }) } + + fn run_warmup_decode( + model: &LlamaModel, + llama_context: &mut LlamaContext<'_>, + n_batch: usize, + desired_slots_total: i32, + ) { + let warmup_tokens = vec![model.token_bos(); 4]; + let mut warmup_batch = match LlamaBatch::new(n_batch, desired_slots_total) { + Ok(warmup_batch) => warmup_batch, + Err(err) => { + warn!("Warmup batch allocation failed: {err:#}"); + return; + } + }; + + for sequence_index in 0..desired_slots_total { + if let Err(err) = warmup_batch.add_sequence(&warmup_tokens, sequence_index, true) { + warn!("Warmup batch add_sequence failed: {err:#}"); + return; + } + } + + llama_context.clear_kv_cache(); + if let Err(err) = llama_context.decode(&mut warmup_batch) { + warn!("Warmup decode failed: {err:#}"); + } + llama_context.synchronize(); + llama_context.clear_kv_cache(); + } } diff --git a/paddler_tests/src/agents_stream_watcher.rs b/paddler_tests/src/agents_stream_watcher.rs index ad5dd624..0f93aba9 100644 --- a/paddler_tests/src/agents_stream_watcher.rs +++ b/paddler_tests/src/agents_stream_watcher.rs @@ -87,6 +87,38 @@ impl AgentsStreamWatcher { )) } + pub async fn wait_for_agent_ready( + &mut self, + agent_name: &str, + expected_slot_count: i32, + ) -> Result { + let predicate_name = agent_name.to_owned(); + let snapshot = self + .until(move |snapshot| { + snapshot.agents.iter().any(|registered_agent| { + registered_agent.name.as_deref() == Some(predicate_name.as_str()) + && (registered_agent.slots_total == expected_slot_count + || !registered_agent.issues.is_empty()) + }) + }) + .await + .with_context(|| format!("agent {agent_name:?} did not reach slot readiness"))?; + + let agent_with_issues = snapshot.agents.iter().find(|registered_agent| { + registered_agent.name.as_deref() == Some(agent_name) + && !registered_agent.issues.is_empty() + }); + + if let Some(failing_agent) = agent_with_issues { + bail!( + "agent {agent_name:?} reported issues during startup: {issues:?}", + issues = failing_agent.issues, + ); + } + + Ok(snapshot) + } + pub async fn wait_for_slots_ready(&mut self, expected_slot_counts: &[i32]) -> Result<()> { let mut expected_sorted: Vec = expected_slot_counts.to_vec(); expected_sorted.sort_unstable(); @@ -149,6 +181,14 @@ mod tests { fn snapshot_with_agent( agent_id: &str, issues: BTreeSet, + ) -> AgentControllerSnapshot { + snapshot_with_agent_and_slots(agent_id, issues, 0) + } + + fn snapshot_with_agent_and_slots( + agent_id: &str, + issues: BTreeSet, + slots_total: i32, ) -> AgentControllerSnapshot { AgentControllerSnapshot { desired_slots_total: 1, @@ -160,7 +200,7 @@ mod tests { model_path: None, name: Some(agent_id.to_owned()), slots_processing: 0, - slots_total: 0, + slots_total, state_application_status: AgentStateApplicationStatus::Fresh, uses_chat_template_override: false, } @@ -270,4 +310,88 @@ mod tests { Ok(()) } + + #[tokio::test] + async fn wait_for_agent_ready_returns_snapshot_when_named_agent_reaches_slot_count() + -> Result<()> { + let agent_id = "agent-warm-0"; + let snapshots = vec![ + AgentControllerPoolSnapshot { + agents: vec![snapshot_with_agent_and_slots(agent_id, BTreeSet::new(), 0)], + }, + AgentControllerPoolSnapshot { + agents: vec![snapshot_with_agent_and_slots(agent_id, BTreeSet::new(), 2)], + }, + ]; + + let mut watcher = make_watcher(snapshots); + + let snapshot = watcher.wait_for_agent_ready(agent_id, 2).await?; + + assert!( + snapshot + .agents + .iter() + .any(|agent| { agent.name.as_deref() == Some(agent_id) && agent.slots_total == 2 }), + "returned snapshot must contain the named agent at its target slot count" + ); + + Ok(()) + } + + #[tokio::test] + async fn wait_for_agent_ready_errors_when_named_agent_reports_issues() -> Result<()> { + let agent_id = "agent-warm-1"; + let snapshots = vec![AgentControllerPoolSnapshot { + agents: vec![snapshot_with_agent_and_slots( + agent_id, + unable_to_find_chat_template_issue(), + 0, + )], + }]; + + let mut watcher = make_watcher(snapshots); + + let error = watcher + .wait_for_agent_ready(agent_id, 2) + .await + .err() + .context("wait_for_agent_ready must surface agent-side issues as an error")?; + let rendered = format!("{error:#}"); + + assert!( + rendered.contains(agent_id), + "error must name the failing agent, got: {rendered}" + ); + assert!( + rendered.contains("issues"), + "error must mention that issues were registered, got: {rendered}" + ); + + Ok(()) + } + + #[tokio::test] + async fn wait_for_agent_ready_errors_when_stream_closes_before_match() -> Result<()> { + let agent_id = "agent-warm-2"; + let snapshots = vec![AgentControllerPoolSnapshot { + agents: vec![snapshot_with_agent_and_slots(agent_id, BTreeSet::new(), 0)], + }]; + + let mut watcher = make_watcher(snapshots); + + let error = watcher + .wait_for_agent_ready(agent_id, 2) + .await + .err() + .context("wait_for_agent_ready must error when the stream ends without a match")?; + let rendered = format!("{error:#}"); + + assert!( + rendered.contains("slot readiness"), + "error must mention that slot readiness was not reached, got: {rendered}" + ); + + Ok(()) + } } diff --git a/paddler_tests/src/start_subprocess_cluster.rs b/paddler_tests/src/start_subprocess_cluster.rs index cc21b568..b676e8c7 100644 --- a/paddler_tests/src/start_subprocess_cluster.rs +++ b/paddler_tests/src/start_subprocess_cluster.rs @@ -3,6 +3,7 @@ use std::process::Stdio; use anyhow::Context as _; use anyhow::Result; use paddler_client::PaddlerClient; +use paddler_types::agent_controller_pool_snapshot::AgentControllerPoolSnapshot; use tokio::process::Child; use tokio_util::sync::CancellationToken; @@ -92,6 +93,7 @@ pub async fn start_subprocess_cluster( let expected_agent_count = agents.len(); let mut agent_children: Vec = Vec::with_capacity(expected_agent_count); + let mut last_ready_snapshot: Option = None; for agent in &agents { let agent_child = paddler_command() @@ -108,12 +110,23 @@ pub async fn start_subprocess_cluster( .context("failed to spawn paddler agent subprocess")?; agent_children.push(agent_child); + + if wait_for_slots_ready { + last_ready_snapshot = Some( + agents_watcher + .wait_for_agent_ready(&agent.name, agent.slot_count) + .await?, + ); + } } - let registered_snapshot = agents_watcher - .until(move |snapshot| snapshot.agents.len() >= expected_agent_count) - .await - .context("not all subprocess agents registered")?; + let registered_snapshot = match last_ready_snapshot { + Some(snapshot) => snapshot, + None => agents_watcher + .until(move |snapshot| snapshot.agents.len() >= expected_agent_count) + .await + .context("not all subprocess agents registered")?, + }; let agent_ids: Vec = registered_snapshot .agents @@ -121,14 +134,6 @@ pub async fn start_subprocess_cluster( .map(|registered_agent| registered_agent.id.clone()) .collect(); - if wait_for_slots_ready { - let expected_slot_counts: Vec = agents.iter().map(|agent| agent.slot_count).collect(); - - agents_watcher - .wait_for_slots_ready(&expected_slot_counts) - .await?; - } - Ok(ClusterHandle::new(ClusterHandleParams { addresses, agent_ids, diff --git a/paddler_tests/src/start_subprocess_cluster_with_qwen3_embedding.rs b/paddler_tests/src/start_subprocess_cluster_with_qwen3_embedding.rs index af4a4fc7..ab5e7069 100644 --- a/paddler_tests/src/start_subprocess_cluster_with_qwen3_embedding.rs +++ b/paddler_tests/src/start_subprocess_cluster_with_qwen3_embedding.rs @@ -1,8 +1,10 @@ use anyhow::Result; use paddler_types::agent_desired_model::AgentDesiredModel; use paddler_types::balancer_desired_state::BalancerDesiredState; +use paddler_types::inference_parameters::InferenceParameters; use crate::cluster_handle::ClusterHandle; +use crate::current_test_device::current_test_device; use crate::model_card::ModelCard; use crate::model_card::qwen3_embedding_0_6b::qwen3_embedding_0_6b; use crate::qwen3_embedding_cluster_params::Qwen3EmbeddingClusterParams; @@ -17,14 +19,27 @@ pub async fn start_subprocess_cluster_with_qwen3_embedding( max_buffered_requests, }: Qwen3EmbeddingClusterParams, ) -> Result { - let ModelCard { reference, .. } = qwen3_embedding_0_6b(); + let ModelCard { + gpu_layer_count, + reference, + } = qwen3_embedding_0_6b(); + + let test_device = current_test_device()?; + test_device.require_available()?; + let device_offload_parameters = + test_device.inference_parameters_for_full_offload(gpu_layer_count); + + let inference_parameters_with_offload = InferenceParameters { + n_gpu_layers: device_offload_parameters.n_gpu_layers, + ..inference_parameters + }; start_subprocess_cluster(SubprocessClusterParams { agents, buffered_request_timeout, desired_state: Some(BalancerDesiredState { chat_template_override: None, - inference_parameters, + inference_parameters: inference_parameters_with_offload, model: AgentDesiredModel::HuggingFace(reference), multimodal_projection: AgentDesiredModel::None, use_chat_template_override: false, diff --git a/paddler_tests/src/subprocess_cluster_params.rs b/paddler_tests/src/subprocess_cluster_params.rs index 1a06764e..03fa0739 100644 --- a/paddler_tests/src/subprocess_cluster_params.rs +++ b/paddler_tests/src/subprocess_cluster_params.rs @@ -23,7 +23,7 @@ impl Default for SubprocessClusterParams { buffered_request_timeout: Duration::from_secs(10), desired_state: Some(BalancerDesiredState::default()), inference_cors_allowed_hosts: Vec::new(), - inference_item_timeout: Duration::from_secs(30), + inference_item_timeout: Duration::from_secs(60), management_cors_allowed_hosts: Vec::new(), max_buffered_requests: 10, state_database_url: "memory://".to_owned(), From a4739819b5360051a5bde30b140e448d540d84ab Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ma=C5=82gorzata=20Zagajewska?= Date: Wed, 13 May 2026 18:01:06 +0200 Subject: [PATCH 51/51] Add regression tests for cluster startup, response forwarding, oversized embeddings, SIGURG, and 504 timeout --- ...h_all_oversized_documents_reports_error.rs | 82 ++++++++++++++ ..._error_when_agent_stops_emitting_chunks.rs | 92 ++++++++++++++++ ...sponse_channel_closes_before_terminator.rs | 96 +++++++++++++++++ ..._does_not_leak_sigurg_to_parent_process.rs | 102 ++++++++++++++++++ ...r_agents_within_sequential_spawn_budget.rs | 60 +++++++++++ 5 files changed, 432 insertions(+) create mode 100644 paddler_tests/tests/agent_embedding_batch_with_all_oversized_documents_reports_error.rs create mode 100644 paddler_tests/tests/balancer_forwards_504_timeout_error_when_agent_stops_emitting_chunks.rs create mode 100644 paddler_tests/tests/balancer_forwards_error_when_response_channel_closes_before_terminator.rs create mode 100644 paddler_tests/tests/paddler_subprocess_cluster_does_not_leak_sigurg_to_parent_process.rs create mode 100644 paddler_tests/tests/subprocess_cluster_starts_four_agents_within_sequential_spawn_budget.rs diff --git a/paddler_tests/tests/agent_embedding_batch_with_all_oversized_documents_reports_error.rs b/paddler_tests/tests/agent_embedding_batch_with_all_oversized_documents_reports_error.rs new file mode 100644 index 00000000..96c04184 --- /dev/null +++ b/paddler_tests/tests/agent_embedding_batch_with_all_oversized_documents_reports_error.rs @@ -0,0 +1,82 @@ +#![cfg(feature = "tests_that_use_llms")] + +use anyhow::Result; +use paddler_tests::agent_config::AgentConfig; +use paddler_tests::collect_embedding_results::collect_embedding_results; +use paddler_tests::inference_http_client::InferenceHttpClient; +use paddler_tests::start_in_process_embedding_cluster::start_in_process_embedding_cluster; +use paddler_types::embedding_input_document::EmbeddingInputDocument; +use paddler_types::embedding_normalization_method::EmbeddingNormalizationMethod; +use paddler_types::inference_parameters::InferenceParameters; +use paddler_types::request_params::GenerateEmbeddingBatchParams; +use reqwest::Client; + +const N_BATCH: u32 = 64; + +#[serial_test::file_serial(model_load, path => "../target/model_load.lock")] +#[tokio::test(flavor = "multi_thread")] +async fn agent_embedding_batch_with_all_oversized_documents_reports_error() -> Result<()> { + let cluster = start_in_process_embedding_cluster( + InferenceParameters { + n_batch: N_BATCH as usize, + context_size: 4096, + enable_embeddings: true, + ..InferenceParameters::default() + }, + AgentConfig::single(1), + ) + .await?; + + let inference_client = + InferenceHttpClient::new(Client::new(), cluster.addresses.inference_base_url()?); + + let huge_content = "The quick brown fox jumps over the lazy dog. ".repeat(40); + + let stream = inference_client + .post_generate_embedding_batch(&GenerateEmbeddingBatchParams { + input_batch: vec![ + EmbeddingInputDocument { + content: huge_content.clone(), + id: "huge-1".to_owned(), + }, + EmbeddingInputDocument { + content: huge_content, + id: "huge-2".to_owned(), + }, + ], + normalization_method: EmbeddingNormalizationMethod::None, + }) + .await?; + + let collected = collect_embedding_results(stream).await?; + + assert_eq!( + collected.embeddings.len(), + 0, + "no embeddings should be produced when all documents are oversized", + ); + assert_eq!( + collected.oversized_documents.len(), + 2, + "both oversized documents should be reported", + ); + assert!( + collected.saw_done, + "stream must terminate with the balancer's final Done so the client unblocks", + ); + assert_eq!( + collected.no_embeddings_produced_count, + 1, + "the agent must terminate its sub-stream with a single NoEmbeddingsProduced variant when zero embeddings are produced; got oversized_documents: {:?}, errors: {:?}", + collected + .oversized_documents + .iter() + .map(|details| &details.source_document_id) + .collect::>(), + collected.errors, + ); + + cluster.shutdown().await?; + + Ok(()) +} diff --git a/paddler_tests/tests/balancer_forwards_504_timeout_error_when_agent_stops_emitting_chunks.rs b/paddler_tests/tests/balancer_forwards_504_timeout_error_when_agent_stops_emitting_chunks.rs new file mode 100644 index 00000000..7a734880 --- /dev/null +++ b/paddler_tests/tests/balancer_forwards_504_timeout_error_when_agent_stops_emitting_chunks.rs @@ -0,0 +1,92 @@ +use std::net::SocketAddr; +use std::sync::Arc; +use std::time::Duration; + +use anyhow::Context as _; +use anyhow::Result; +use anyhow::anyhow; +use paddler::balancer::chunk_forwarding_session_controller::ChunkForwardingSessionController; +use paddler::balancer::chunk_forwarding_session_controller::identity_transformer::IdentityTransformer; +use paddler::balancer::chunk_forwarding_session_controller::transform_result::TransformResult; +use paddler::balancer::embedding_sender_collection::EmbeddingSenderCollection; +use paddler::balancer::inference_service::configuration::Configuration as InferenceServiceConfiguration; +use paddler::balancer::manages_senders_controller::ManagesSendersController; +use paddler::balancer::request_from_agent::forward_responses_stream; +use paddler_tests::make_agent_controller_without_remote_agent::make_agent_controller_without_remote_agent; +use paddler_types::inference_client::Message as OutgoingMessage; +use paddler_types::jsonrpc::ErrorEnvelope; +use tokio::sync::mpsc; +use tokio_util::sync::CancellationToken; + +#[tokio::test(flavor = "multi_thread")] +async fn balancer_forwards_504_timeout_error_when_agent_stops_emitting_chunks() -> Result<()> { + let agent_controller = Arc::new(make_agent_controller_without_remote_agent("test-agent")); + let request_id = "timeout-test-request".to_owned(); + let receive_response_controller: ManagesSendersController = + ManagesSendersController::from_request_id( + request_id.clone(), + agent_controller.embedding_sender_collection.clone(), + )?; + + let (chunk_tx, mut chunk_rx) = mpsc::unbounded_channel::(); + let session_controller = + ChunkForwardingSessionController::new(chunk_tx, IdentityTransformer::new()); + + let connection_close = CancellationToken::new(); + let inference_item_timeout = Duration::from_millis(150); + let configuration = InferenceServiceConfiguration { + addr: SocketAddr::from(([127, 0, 0, 1], 0)), + cors_allowed_hosts: Vec::new(), + inference_item_timeout, + }; + + let agent_controller_clone = agent_controller.clone(); + let request_id_clone = request_id.clone(); + let forward_handle: tokio::task::JoinHandle> = tokio::spawn(async move { + forward_responses_stream::<_, EmbeddingSenderCollection>( + agent_controller_clone, + connection_close, + configuration, + receive_response_controller, + request_id_clone, + session_controller, + ) + .await + }); + + let forward_completed_within = inference_item_timeout * 10; + tokio::time::timeout(forward_completed_within, forward_handle) + .await + .context("forward_responses_stream did not return within the 504-timeout budget")? + .context("forward_responses_stream task panicked")? + .context("forward_responses_stream returned an error")?; + + let chunk = chunk_rx + .recv() + .await + .ok_or_else(|| anyhow!("expected a 504 timeout envelope to be forwarded to the client"))?; + + let serialized = match chunk { + TransformResult::Chunk(serialized) | TransformResult::Error(serialized) => serialized, + TransformResult::Discard => { + return Err(anyhow!( + "expected a Chunk or Error transform result, got Discard" + )); + } + }; + + let envelope: OutgoingMessage = + serde_json::from_str(&serialized).context("failed to parse forwarded envelope as JSON")?; + + let OutgoingMessage::Error(ErrorEnvelope { error, .. }) = envelope else { + return Err(anyhow!("expected an Error envelope")); + }; + + assert_eq!( + error.code, 504, + "expected 504 error code for inference-item timeout, got {}", + error.code, + ); + + Ok(()) +} diff --git a/paddler_tests/tests/balancer_forwards_error_when_response_channel_closes_before_terminator.rs b/paddler_tests/tests/balancer_forwards_error_when_response_channel_closes_before_terminator.rs new file mode 100644 index 00000000..38c16a9f --- /dev/null +++ b/paddler_tests/tests/balancer_forwards_error_when_response_channel_closes_before_terminator.rs @@ -0,0 +1,96 @@ +use std::net::SocketAddr; +use std::sync::Arc; +use std::time::Duration; + +use anyhow::Context as _; +use anyhow::Result; +use anyhow::anyhow; +use paddler::balancer::chunk_forwarding_session_controller::ChunkForwardingSessionController; +use paddler::balancer::chunk_forwarding_session_controller::identity_transformer::IdentityTransformer; +use paddler::balancer::chunk_forwarding_session_controller::transform_result::TransformResult; +use paddler::balancer::embedding_sender_collection::EmbeddingSenderCollection; +use paddler::balancer::inference_service::configuration::Configuration as InferenceServiceConfiguration; +use paddler::balancer::manages_senders::ManagesSenders as _; +use paddler::balancer::manages_senders_controller::ManagesSendersController; +use paddler::balancer::request_from_agent::forward_responses_stream; +use paddler_tests::make_agent_controller_without_remote_agent::make_agent_controller_without_remote_agent; +use paddler_types::inference_client::Message as OutgoingMessage; +use paddler_types::jsonrpc::ErrorEnvelope; +use tokio::sync::mpsc; +use tokio_util::sync::CancellationToken; + +#[tokio::test(flavor = "multi_thread")] +async fn forward_responses_stream_emits_error_envelope_when_response_channel_closes_before_terminator() +-> Result<()> { + let agent_controller = Arc::new(make_agent_controller_without_remote_agent("test-agent")); + let request_id = "test-request".to_owned(); + let receive_response_controller: ManagesSendersController = + ManagesSendersController::from_request_id( + request_id.clone(), + agent_controller.embedding_sender_collection.clone(), + )?; + + let (chunk_tx, mut chunk_rx) = mpsc::unbounded_channel::(); + let session_controller = + ChunkForwardingSessionController::new(chunk_tx, IdentityTransformer::new()); + + let connection_close = CancellationToken::new(); + let configuration = InferenceServiceConfiguration { + addr: SocketAddr::from(([127, 0, 0, 1], 0)), + cors_allowed_hosts: Vec::new(), + inference_item_timeout: Duration::from_secs(30), + }; + + let agent_controller_clone = agent_controller.clone(); + let request_id_clone = request_id.clone(); + let forward_handle: tokio::task::JoinHandle> = tokio::spawn(async move { + forward_responses_stream::<_, EmbeddingSenderCollection>( + agent_controller_clone, + connection_close, + configuration, + receive_response_controller, + request_id_clone, + session_controller, + ) + .await + }); + + tokio::time::sleep(Duration::from_millis(50)).await; + + agent_controller + .embedding_sender_collection + .deregister_sender(request_id.clone())?; + + tokio::time::timeout(Duration::from_secs(5), forward_handle) + .await + .context("forward_responses_stream did not complete in time")? + .context("forward_responses_stream task panicked")? + .context("forward_responses_stream returned an error")?; + + let chunk = chunk_rx + .recv() + .await + .ok_or_else(|| anyhow!("expected an error envelope to be forwarded to the client"))?; + + let serialized = match chunk { + TransformResult::Chunk(serialized) | TransformResult::Error(serialized) => serialized, + TransformResult::Discard => { + return Err(anyhow!("expected a Chunk transform result, got Discard")); + } + }; + + let envelope: OutgoingMessage = + serde_json::from_str(&serialized).context("failed to parse forwarded envelope as JSON")?; + + let OutgoingMessage::Error(ErrorEnvelope { error, .. }) = envelope else { + return Err(anyhow!("expected an Error envelope")); + }; + + assert_eq!( + error.code, 502, + "expected 502 error code for premature channel close, got {}", + error.code + ); + + Ok(()) +} diff --git a/paddler_tests/tests/paddler_subprocess_cluster_does_not_leak_sigurg_to_parent_process.rs b/paddler_tests/tests/paddler_subprocess_cluster_does_not_leak_sigurg_to_parent_process.rs new file mode 100644 index 00000000..95c0effe --- /dev/null +++ b/paddler_tests/tests/paddler_subprocess_cluster_does_not_leak_sigurg_to_parent_process.rs @@ -0,0 +1,102 @@ +#![cfg(all( + unix, + feature = "tests_that_use_compiled_paddler", + feature = "tests_that_use_llms" +))] + +use std::sync::Arc; +use std::sync::atomic::AtomicUsize; +use std::sync::atomic::Ordering; + +use anyhow::Context as _; +use anyhow::Result; +use nix::sys::signal::Signal; +use paddler_tests::agent_config::AgentConfig; +use paddler_tests::collect_embedding_results::collect_embedding_results; +use paddler_tests::inference_http_client::InferenceHttpClient; +use paddler_tests::qwen3_embedding_cluster_params::Qwen3EmbeddingClusterParams; +use paddler_tests::start_subprocess_cluster_with_qwen3_embedding::start_subprocess_cluster_with_qwen3_embedding; +use paddler_types::embedding_input_document::EmbeddingInputDocument; +use paddler_types::embedding_normalization_method::EmbeddingNormalizationMethod; +use paddler_types::inference_parameters::InferenceParameters; +use paddler_types::request_params::GenerateEmbeddingBatchParams; +use reqwest::Client; +use tokio::signal::unix::SignalKind; +use tokio::signal::unix::signal; +use tokio_util::sync::CancellationToken; + +#[serial_test::file_serial(model_load, path => "../target/model_load.lock")] +#[tokio::test(flavor = "multi_thread")] +async fn paddler_subprocess_cluster_does_not_leak_sigurg_to_parent_process() -> Result<()> { + let observed_sigurg_count = Arc::new(AtomicUsize::new(0)); + let observer_shutdown = CancellationToken::new(); + + let observer_count = observed_sigurg_count.clone(); + let observer_token = observer_shutdown.clone(); + let mut sigurg_stream = signal(SignalKind::from_raw(Signal::SIGURG as i32)) + .context("failed to install SIGURG observer on the test process")?; + + let observer_handle = tokio::spawn(async move { + loop { + tokio::select! { + () = observer_token.cancelled() => break, + signal_event = sigurg_stream.recv() => match signal_event { + Some(()) => { + observer_count.fetch_add(1, Ordering::SeqCst); + } + None => break, + }, + } + } + }); + + let cluster = start_subprocess_cluster_with_qwen3_embedding(Qwen3EmbeddingClusterParams { + agents: AgentConfig::uniform(2, 2), + inference_parameters: InferenceParameters { + enable_embeddings: true, + ..InferenceParameters::default() + }, + ..Qwen3EmbeddingClusterParams::default() + }) + .await?; + + let inference_client = + InferenceHttpClient::new(Client::new(), cluster.addresses.inference_base_url()?); + + let input_batch: Vec = (0..4) + .map(|document_index| EmbeddingInputDocument { + content: format!("SIGURG regression document number {document_index:02}"), + id: format!("doc-{document_index}"), + }) + .collect(); + let params = GenerateEmbeddingBatchParams { + input_batch, + normalization_method: EmbeddingNormalizationMethod::None, + }; + + let stream = inference_client + .post_generate_embedding_batch(¶ms) + .await?; + let collected = collect_embedding_results(stream).await?; + + assert_eq!(collected.embeddings.len(), 4); + assert!(collected.errors.is_empty()); + + cluster.shutdown().await?; + + observer_shutdown.cancel(); + observer_handle + .await + .context("SIGURG observer task panicked")?; + + let final_sigurg_count = observed_sigurg_count.load(Ordering::SeqCst); + + assert_eq!( + final_sigurg_count, 0, + "paddler subprocesses leaked {final_sigurg_count} SIGURG signals to the parent process; \ + this would kill bash test harness loops that rely on SIGURG's default ignore action being honored. \ + The observer ran throughout cluster startup, an embedding inference, and cluster shutdown." + ); + + Ok(()) +} diff --git a/paddler_tests/tests/subprocess_cluster_starts_four_agents_within_sequential_spawn_budget.rs b/paddler_tests/tests/subprocess_cluster_starts_four_agents_within_sequential_spawn_budget.rs new file mode 100644 index 00000000..6057a314 --- /dev/null +++ b/paddler_tests/tests/subprocess_cluster_starts_four_agents_within_sequential_spawn_budget.rs @@ -0,0 +1,60 @@ +#![cfg(all( + feature = "tests_that_use_compiled_paddler", + feature = "tests_that_use_llms" +))] + +use std::time::Duration; +use std::time::Instant; + +use anyhow::Result; +use paddler_tests::agent_config::AgentConfig; +use paddler_tests::qwen3_embedding_cluster_params::Qwen3EmbeddingClusterParams; +use paddler_tests::start_subprocess_cluster_with_qwen3_embedding::start_subprocess_cluster_with_qwen3_embedding; +use paddler_types::inference_parameters::InferenceParameters; + +#[serial_test::file_serial(model_load, path => "../target/model_load.lock")] +#[tokio::test(flavor = "multi_thread")] +async fn subprocess_cluster_starts_four_agents_within_sequential_spawn_budget() -> Result<()> { + let agent_count: usize = 4; + let single_agent_init_budget = Duration::from_secs(8); + let cluster_overhead_budget = Duration::from_secs(8); + #[expect( + clippy::cast_possible_truncation, + reason = "agent_count is a fixed test constant that fits in u32" + )] + let cluster_startup_budget = + single_agent_init_budget * (agent_count as u32) + cluster_overhead_budget; + + let cluster_startup_started_at = Instant::now(); + + let cluster = start_subprocess_cluster_with_qwen3_embedding(Qwen3EmbeddingClusterParams { + agents: AgentConfig::uniform(agent_count, 2), + inference_parameters: InferenceParameters { + enable_embeddings: true, + ..InferenceParameters::default() + }, + ..Qwen3EmbeddingClusterParams::default() + }) + .await?; + + let cluster_startup_elapsed = cluster_startup_started_at.elapsed(); + + assert_eq!( + cluster.agent_ids.len(), + agent_count, + "expected {agent_count} agents to register; got {actual}", + actual = cluster.agent_ids.len(), + ); + + cluster.shutdown().await?; + + assert!( + cluster_startup_elapsed <= cluster_startup_budget, + "cluster startup took {cluster_startup_elapsed:?}, expected within {cluster_startup_budget:?}. \ + Under concurrent agent spawn on Metal, kernel-compile contention can starve a single agent \ + for 60-120s. Sequential spawn isolates each agent's Metal init and keeps total startup \ + within {single_agent_init_budget:?} per agent plus {cluster_overhead_budget:?} of overhead." + ); + + Ok(()) +}