From 8d339a6f1fce0232ae8059288fca8fa317b6393e Mon Sep 17 00:00:00 2001 From: Mateusz Charytoniuk Date: Wed, 7 Oct 2026 16:14:29 +0200 Subject: [PATCH 001/128] Fix each cluster to one inference mode and serve kev decisions natively and through a TypeSafe-compatible API --- .claude/rules/paddler-agent-decision.md | 15 + .claude/rules/paddler-agent-embeddings.md | 10 + .claude/rules/paddler-agent-pointer-head.md | 11 + .claude/rules/paddler-agent-runner.md | 10 + .claude/rules/paddler-agent-runtime.md | 11 + .claude/rules/paddler-agent-status.md | 3 +- .../rules/paddler-agent-text-generation.md | 10 + .claude/rules/paddler-agent.md | 6 +- .claude/rules/paddler-balancer-runner.md | 10 + .claude/rules/paddler-balancer.md | 17 +- .claude/rules/paddler-bootstrap.md | 11 - .claude/rules/paddler-cli.md | 2 +- .claude/rules/paddler-client-javascript.md | 2 +- .claude/rules/paddler-client-python.md | 12 - .claude/rules/paddler-client.md | 2 +- .claude/rules/paddler-kev-converter-python.md | 15 + .claude/rules/paddler-openai-translation.md | 13 + .claude/rules/paddler-service-thread.md | 10 + .claude/rules/paddler-test-cluster-harness.md | 8 +- .claude/rules/paddler-tests.md | 1 + .claude/rules/paddler-typesafe-translation.md | 13 + .claude/skills/running-all-tests/SKILL.md | 28 +- .claude/skills/running-coverage/SKILL.md | 12 +- .github/workflows/test.yml | 31 +- Cargo.lock | 282 +- Cargo.toml | 70 +- Makefile | 127 +- README.md | 46 +- fixtures/kev_0_8b_parity_reference.json | 134 + fixtures/pointer_head_with_f16_tensors.gguf | Bin 0 -> 4864 bytes ...nter_head_with_mismatched_projections.gguf | Bin 0 -> 8768 bytes .../pointer_head_with_zero_temperature.gguf | Bin 0 -> 9024 bytes fixtures/pointer_head_without_delimiters.gguf | Bin 0 -> 8672 bytes .../pointer_head_without_temperature.gguf | Bin 0 -> 8960 bytes ...inter_head_with_plain_text_delimiters.gguf | Bin 0 -> 131840 bytes .../qwen3_5_0_8b_synthetic_pointer_head.gguf | Bin 0 -> 131904 bytes ...c_pointer_head_with_wrong_hidden_size.gguf | Bin 0 -> 66368 bytes fixtures/qwen3_5_tokenizer_reference.json | 276 ++ fixtures/typesafe_reference.json | 353 ++ flake.lock | 6 +- flake.nix | 56 +- jarmuz-fmt.mjs | 11 +- jarmuz/run-website.mjs | 11 +- jarmuz/worker-paddler.mjs | 2 +- package-lock.json | 2 +- paddler_agent/Cargo.toml | 45 +- paddler_agent/src/agent_applicable_model.rs | 11 - paddler_agent/src/agent_applicable_state.rs | 58 +- .../src/agent_applicable_state_holder.rs | 15 +- .../src/agent_desired_state_converter.rs | 259 +- paddler_agent/src/agent_error.rs | 15 + paddler_agent/src/balancer_message_context.rs | 7 +- .../src/chat_template_load_status.rs | 4 - paddler_agent/src/continuous_batch_arbiter.rs | 525 --- .../src/continuous_batch_arbiter_handle.rs | 35 - .../continuous_batch_arbiter_spawn_outcome.rs | 6 - .../src/continuous_batch_arbiter_state.rs | 218 - .../continuous_batch_preparation_request.rs | 142 - .../src/continuous_batch_request_preparer.rs | 100 - .../src/continuous_batch_scheduler_command.rs | 8 - paddler_agent/src/desired_state_reconciler.rs | 3 +- .../src/embedding_batch_rejection.rs | 129 - .../src/forward_scheduler_command.rs | 174 - paddler_agent/src/lib.rs | 76 +- paddler_agent/src/llamacpp_arbiter_service.rs | 42 +- .../src/management_socket_client_service.rs | 53 +- paddler_agent/src/model_metadata_holder.rs | 63 - paddler_agent/src/pipeline_arbiter_state.rs | 286 ++ paddler_agent/src/pipeline_request.rs | 277 ++ .../src/prepared_embedding_batch_request.rs | 17 - .../src/prepared_generation_request.rs | 22 - paddler_agent/src/reconciliation_service.rs | 9 +- .../src/require_embeddings_enabled.rs | 32 - paddler_agent/src/running_pipeline.rs | 70 + paddler_agent/src/token_generation.rs | 39 - paddler_agent_decision/Cargo.toml | 31 + .../src/active_decision_request.rs | 230 + .../src/decision_admission.rs | 10 + .../src/decision_advance.rs | 4 + .../src/decision_capacity_ledger.rs | 125 + .../src/decision_decoder.rs | 122 + .../src/decision_delimiter_tokens.rs | 79 + paddler_agent_decision/src/decision_error.rs | 250 + .../src/decision_pipeline.rs | 262 ++ .../src/decision_question_row.rs | 38 + .../src/decision_request_phase.rs | 5 + .../src/decision_request_preparer.rs | 107 + .../src/decision_scheduler.rs | 154 + .../src/decision_scheduler_context.rs | 7 + .../src/decision_text_tokenizer.rs | 25 + .../src/decision_token_layout.rs | 200 + paddler_agent_decision/src/lib.rs | 19 + .../src/prepared_decision_request.rs | 73 + .../src/promised_decision_cells.rs | 14 + paddler_agent_decision/src/question_lane.rs | 6 + .../src/tokenized_decision_question.rs | 7 + paddler_agent_embeddings/Cargo.toml | 29 + .../src/agent_pooling_type.rs | 0 .../src/converts_to_llama_pooling_type.rs | 0 .../src/embedding_batch_preparer.rs | 47 +- .../src/embedding_batch_processor.rs | 87 +- .../src/embedding_error.rs | 213 + .../src/embedding_input_tokenized.rs | 0 .../src/embedding_pipeline.rs | 142 + .../src/embedding_scheduler.rs | 60 + .../src/embedding_scheduler_context.rs | 8 + paddler_agent_embeddings/src/lib.rs | 13 + .../src/normalization/l2.rs | 0 .../src/normalization/mod.rs | 0 .../src/normalization/normalize_embedding.rs | 10 +- .../src/normalization/rms_norm.rs | 0 .../src/plan_embedding_batches.rs | 0 .../src/prepared_embedding_batch_request.rs | 63 + .../src/sequenced_embedding_input.rs | 6 + paddler_agent_pointer_head/Cargo.toml | 27 + paddler_agent_pointer_head/src/lib.rs | 4 + .../src/pointer_head.rs | 206 + .../src/pointer_head_delimiters.rs | 47 + .../src/pointer_head_error.rs | 37 + .../src/pointer_head_projection.rs | 39 + ...ointer_head_with_f16_tensors_is_refused.rs | 12 + ..._with_mismatched_projections_is_refused.rs | 16 + ...r_head_with_zero_temperature_is_refused.rs | 12 + ..._head_without_its_delimiters_is_refused.rs | 12 + ...head_without_its_temperature_is_refused.rs | 12 + ...unreadable_pointer_head_file_is_refused.rs | 14 + .../tests/fixture_path.rs | 8 + ...nthetic_pointer_head_reads_its_contract.rs | 26 + paddler_agent_pointer_head/tests/main.rs | 8 + paddler_agent_runner/Cargo.toml | 39 + .../src/agent_runner.rs | 13 +- .../src/agent_runner_config.rs | 2 +- .../src/agent_runner_params.rs | 4 +- .../src/agent_service_bundle.rs | 22 +- paddler_agent_runner/src/lib.rs | 5 + .../src/llama_cpp_max_sequences.rs | 0 ..._when_management_server_is_unresponsive.rs | 8 +- .../agent_runner_completes_when_cancelled.rs | 2 +- ...etes_when_its_parent_token_is_cancelled.rs | 2 +- ..._runner_params_for_unreachable_balancer.rs | 6 +- .../tests/agent_runner_stops_when_dropped.rs | 2 +- paddler_agent_runner/tests/main.rs | 5 + paddler_agent_runtime/Cargo.toml | 35 + .../src/agent_kv_cache_dtype.rs | 0 .../src/agent_request.rs | 2 +- .../src/agent_runtime_error.rs | 37 + .../src/await_scheduler_startup.rs | 37 + .../src/converts_to_llama_kv_cache_dtype.rs | 0 .../src/describe_error_with_causes.rs | 33 + .../src/inference_runtime_context.rs | 2 +- .../src/inference_thread_count.rs | 32 + .../src/join_scheduler_thread.rs | 6 +- .../join_scheduler_thread_in_background.rs | 23 + paddler_agent_runtime/src/lib.rs | 27 + .../src/llama_context_settings.rs | 71 + .../src/loaded_llama_model.rs | 142 + .../src/loaded_model_metadata.rs | 7 + .../src/model_metadata_holder.rs | 79 + .../src/prepares_scheduler_command.rs | 12 + .../src/receives_stop_request.rs | 0 .../src/rejection_description.rs | 0 .../src/scheduler_command.rs | 3 + paddler_agent_runtime/src/scheduler_handle.rs | 30 + .../src/scheduler_message.rs | 4 + .../src/scheduler_request_preparer.rs | 56 + .../src/scheduler_spawn_outcome.rs | 7 + .../src/send_result_or_warn.rs | 0 .../src/send_startup_signal.rs | 0 .../src/sequence_id_guard.rs | 0 .../src/sequence_id_pool.rs | 0 .../src/startup_signal_delivery.rs | 0 ...lled_startup_joins_the_scheduler_thread.rs | 52 + ...ails_before_readiness_reports_its_error.rs | 30 + ...scheduler_that_signals_readiness_starts.rs | 41 + ...ds_for_a_stopped_scheduler_are_rejected.rs | 22 + .../tests/even_number_preparation.rs | 25 + .../inference_runtime_context_fixture.rs | 13 + ...sumes_the_panic_of_the_scheduler_thread.rs | 13 + ...sumes_the_panic_of_the_scheduler_thread.rs | 13 + ...d_runs_reports_that_it_cannot_be_loaded.rs | 38 + paddler_agent_runtime/tests/main.rs | 14 + paddler_agent_runtime/tests/number_command.rs | 15 + .../tests/number_request_preparer.rs | 37 + ...reach_the_scheduler_before_its_shutdown.rs | 28 + ...ation_rejects_never_reach_the_scheduler.rs | 30 + ...own_returns_the_scheduler_thread_result.rs | 33 + paddler_agent_status/src/agent_issue_fix.rs | 39 + paddler_agent_status/src/lib.rs | 1 + .../src/slot_aggregated_status.rs | 78 +- .../src/slot_guard.rs | 7 +- paddler_agent_text_generation/Cargo.toml | 37 + .../src/chat_prompt_render_request.rs | 0 .../src/chat_prompt_renderer.rs | 12 +- .../src/chat_template_renderer/mod.rs | 0 .../chat_template_renderer/pyjinja_tojson.rs | 0 .../chat_template_renderer/raise_exception.rs | 0 .../src/continuous_batch_active_request.rs | 4 +- .../src/continuous_batch_generation_step.rs | 0 .../src/continuous_batch_request_phase.rs | 0 .../src/continuous_batch_request_state.rs | 0 .../advance_generating_phase.rs | 11 +- .../advance_outcome.rs | 0 .../assemble_batch_phase.rs | 0 .../continuous_batch_scheduler/batch_pass.rs | 0 .../classified_token.rs | 0 .../commit_phase.rs | 0 .../completion_check_outcome.rs | 0 .../completion_check_phase.rs | 0 .../contributions.rs | 0 .../decode_failure_phase.rs | 0 .../emit_token_outcome.rs | 0 .../emit_token_phase.rs | 0 .../generating_contribution.rs | 0 .../ingesting_contribution.rs | 0 .../src/continuous_batch_scheduler/mod.rs | 134 +- .../sequence_ordered_insertion_index.rs | 0 .../src/continuous_batch_scheduler_context.rs | 4 +- .../src/continuous_batch_scheduler_params.rs | 6 +- .../src/continuous_batch_terminal_delivery.rs | 8 +- .../src/continuous_batch_terminal_outcome.rs | 0 .../src/converts_to_mtmd_bitmap.rs | 0 .../src/generation_request_preparer.rs | 92 +- .../src/grammar_sampling.rs | 0 .../src/image_input.rs | 12 +- paddler_agent_text_generation/src/lib.rs | 41 + .../src/model_bos_token.rs | 0 .../src/multimodal_ingestion_progress.rs | 0 .../src/multimodal_projection.rs | 7 + .../src/multimodal_prompt_ingestion.rs | 0 .../src/multimodal_prompt_support.rs | 8 +- .../src/prepared_generation_request.rs | 90 + .../src/prepared_prompt.rs | 0 .../src/prompt_modality.rs | 0 .../src/prompt_tokenizer.rs | 16 +- ...equire_grammar_compatible_with_thinking.rs | 10 +- .../require_prompt_fits_sequence_context.rs | 10 +- .../src/resolve_grammar.rs | 10 +- .../src/sampler_chain_factory.rs | 30 +- .../src/sampling_outcome.rs | 0 .../src/text_generation_error.rs | 176 +- .../src/text_generation_pipeline.rs | 344 ++ .../src/text_generation_request.rs | 15 + .../src/text_generation_settings.rs | 14 + .../src/token_classification.rs | 0 .../src/token_generation_support.rs | 0 .../src/token_sampling.rs | 13 +- .../src/tool_call_buffer.rs | 0 .../src/tool_call_event.rs | 0 .../src/tool_call_handling.rs | 0 .../src/tool_call_pipeline.rs | 0 paddler_balancer/Cargo.toml | 8 + paddler_balancer/src/agent_controller.rs | 7 +- paddler_balancer/src/agent_controller_pool.rs | 122 +- .../src/agent_controller_pool_updates.rs | 35 + .../src/agent_controller_slot_guard.rs | 11 +- .../src/agent_response_senders.rs | 2 + .../src/agent_streaming_request.rs | 12 + paddler_balancer/src/balancer_addresses.rs | 4 +- .../src/balancer_applicable_state.rs | 61 +- .../src/balancer_applicable_state_holder.rs | 54 +- .../src/buffered_request_manager.rs | 39 +- .../identity_transformer.rs | 2 +- .../mod.rs | 7 +- .../src/cluster_token_generation_mode.rs | 50 - .../agent_result_stream_event.rs | 6 + .../agent_result_stream_transformer.rs | 130 + .../src/compatibility/attach_request_id.rs | 30 + .../compatibility/compatibility_app_data.rs | 49 + .../compatibility/compatibility_service.rs | 73 + ...=> compatibility_service_configuration.rs} | 2 +- .../create_compatibility_cors_middleware.rs | 19 + paddler_balancer/src/compatibility/mod.rs | 10 + .../compatibility/openai_service/app_data.rs | 14 - .../chat_completion_chunk_payload.rs | 8 - .../chat_completions_sse_response.rs | 138 +- .../http_route/post_chat_completions.rs | 272 +- .../http_route/post_responses.rs | 120 +- .../src/compatibility/openai_service/mod.rs | 141 +- .../openai_compatibility_layer.rs | 20 + .../openai_default_max_tokens.rs | 3 - .../openai_service/openai_error.rs | 371 -- .../openai_service/openai_error_body.rs | 52 + .../openai_service/openai_failure.rs | 200 + .../openai_service/openai_header.rs | 5 + .../openai_service/openai_http_response.rs | 156 + .../openai_service/openai_json_config.rs | 6 +- .../openai_service/openai_json_response.rs | 113 - ...enai_non_streaming_response_transformer.rs | 543 --- .../openai_non_streaming_state.rs | 7 - ...nai_responses_function_call_output_item.rs | 8 - .../openai_responses_function_output.rs | 51 - .../openai_responses_input_item.rs | 47 - .../openai_responses_reasoning.rs | 16 - .../openai_responses_tagged_item.rs | 16 - .../openai_service/openai_responses_tool.rs | 10 - .../openai_sse_http_response.rs | 15 + .../openai_streaming_response_transformer.rs | 770 ---- .../openai_service/openai_streaming_state.rs | 4 - ...nses_non_streaming_response_transformer.rs | 346 -- .../responses_non_streaming_state.rs | 8 - .../responses_response_header.rs | 138 - .../openai_service/responses_sse_response.rs | 130 +- ...esponses_streaming_response_transformer.rs | 476 -- .../responses_streaming_state.rs | 225 - .../openai_service/timestamp_from.rs | 35 - .../try_universal_error_chunk.rs | 10 - .../serves_compatibility_layer.rs | 9 + .../typesafe_service/http_route/get_models.rs | 20 + .../typesafe_service/http_route/mod.rs | 2 + .../http_route/post_system_one.rs | 34 + .../src/compatibility/typesafe_service/mod.rs | 9 + .../system_one_http_response.rs | 281 ++ .../typesafe_service/typesafe_api_path.rs | 6 + .../typesafe_compatibility_layer.rs | 24 + .../typesafe_service/typesafe_error_body.rs | 15 + .../typesafe_service/typesafe_header.rs | 8 + .../typesafe_service/typesafe_json_config.rs | 17 + .../typesafe_service/typesafe_model.rs | 21 + .../typesafe_service/typesafe_models.rs | 30 + .../src/compatibility/upstream_failure.rs | 77 + .../inference_service/http_route/api/mod.rs | 1 + ...post_continue_from_conversation_history.rs | 5 - .../api/post_continue_from_raw_prompt.rs | 5 - .../http_route/api/post_decide.rs | 39 + .../api/post_generate_embedding_batch.rs | 25 +- .../inference_socket_controller_context.rs | 2 - .../http_route/api/ws_inference_socket/mod.rs | 126 +- .../spawn_token_generation_mode_watcher.rs | 81 - .../inference_mode_routes.rs | 21 + paddler_balancer/src/inference_service/mod.rs | 17 +- paddler_balancer/src/lib.rs | 2 +- .../api/get_balancer_applicable_state.rs | 26 +- .../api/get_balancer_desired_state.rs | 29 +- .../http_route/api/get_buffered_requests.rs | 19 +- .../api/put_balancer_desired_state.rs | 86 +- .../http_route/api/ws_agent_socket/mod.rs | 13 + .../src/reconciliation_service.rs | 14 +- paddler_balancer/src/request_from_agent.rs | 21 +- paddler_balancer/src/snapshots_stream.rs | 3 +- .../src/unbounded_stream_from_agent.rs | 5 +- .../src/web_admin_panel_service/mod.rs | 4 + .../web_admin_panel_service/template_data.rs | 53 + .../templates/web_admin_panel.html | 4 + paddler_balancer/tests/main.rs | 1 + ..._closed_after_dispatch_as_a_bad_gateway.rs | 115 + .../Cargo.toml | 16 +- .../src/balancer_defaults.rs | 0 .../src/balancer_runner.rs | 20 +- .../src/balancer_runner_config.rs | 7 +- .../src/balancer_runner_error.rs | 19 +- .../src/balancer_runner_params.rs | 4 +- .../src/balancer_service_bundle.rs | 140 +- .../src/balancer_serving_mode.rs | 44 + paddler_balancer_runner/src/lib.rs | 7 + ..._reports_its_connection_to_the_balancer.rs | 10 +- .../balancer_reports_its_gauges_to_statsd.rs | 6 +- ...alancer_runner_completes_when_cancelled.rs | 2 +- ...etes_when_its_parent_token_is_cancelled.rs | 2 +- ...fails_to_start_when_an_address_is_taken.rs | 60 +- ...art_when_state_database_file_is_corrupt.rs | 8 +- ...t_with_a_zero_statsd_reporting_interval.rs | 8 +- ...r_preserves_the_persisted_desired_state.rs | 36 +- ..._refuses_a_stored_state_of_another_mode.rs | 57 + ..._runner_releases_its_ports_when_dropped.rs | 2 +- ...nner_serves_on_the_addresses_it_reports.rs | 2 +- ...serves_the_desired_state_it_starts_with.rs | 38 + ...es_buffered_request_with_shutdown_error.rs | 2 +- ...anel_omits_the_services_it_does_not_run.rs | 43 + ..._panel_shows_the_balancer_configuration.rs | 59 + ...hows_the_typesafe_compatibility_address.rs | 36 + .../tests/ephemeral_balancer_runner_params.rs | 15 +- .../tests/main.rs | 12 +- .../tests/rendered_dashboard_attributes.rs | 42 + paddler_bootstrap/src/lib.rs | 13 - ...anel_omits_the_services_it_does_not_run.rs | 73 - ..._panel_shows_the_balancer_configuration.rs | 88 - paddler_cli/Cargo.toml | 13 +- paddler_cli/src/cmd/agent.rs | 10 +- paddler_cli/src/cmd/balancer.rs | 176 +- paddler_cli/src/cmd/balancer_decision.rs | 38 + paddler_cli/src/cmd/balancer_embeddings.rs | 24 + .../src/cmd/balancer_shared_arguments.rs | 163 + .../src/cmd/balancer_text_generation.rs | 38 + paddler_cli/src/cmd/mod.rs | 4 + paddler_cli/src/lib.rs | 9 +- .../tests/clap_usage_error_exit_code.rs | 1 + paddler_cli/tests/main.rs | 5 +- ...e_when_its_management_address_is_taken.rs} | 3 +- ...uses_compatibility_flags_of_other_modes.rs | 28 + ...re_when_its_management_address_is_taken.rs | 26 + ...rints_help_and_exits_with_a_usage_error.rs | 2 +- .../src/start_subprocess_cluster.rs | 23 +- .../start_subprocess_cluster_with_qwen3.rs | 3 +- ...tes_buffered_requests_across_two_agents.rs | 6 +- ...stributes_embedding_batch_across_agents.rs | 8 +- ...g_batch_across_agents_with_uneven_slots.rs | 8 +- ...es_embedding_burst_evenly_across_agents.rs | 8 +- ...ing_burst_exceeds_max_buffered_requests.rs | 7 +- ..._fans_out_embedding_batch_to_all_agents.rs | 8 +- ...ithin_the_default_macos_open_file_limit.rs | 1 + ...er_serves_on_the_addresses_it_announces.rs | 3 +- ...mpatibility_on_the_address_it_announces.rs | 36 + ...el_shows_its_command_line_configuration.rs | 2 + paddler_cli_tests/tests/main.rs | 1 + paddler_client/src/client_inference.rs | 17 +- .../src/inference_socket/connection.rs | 23 +- .../inbound_message_router.rs | 48 +- paddler_client/src/inference_socket/pool.rs | 13 +- .../src/inference_socket/spawn_read_task.rs | 9 +- paddler_client_javascript/package.json | 2 +- .../src/inferenceSocketClient.ts | 25 - .../src/schemas/AgentIssue.ts | 31 + .../src/schemas/AgentRuntimeStatus.ts | 19 + .../src/schemas/AgentStatus.ts | 3 +- .../src/schemas/BalancerDesiredState.ts | 10 +- .../src/schemas/BalancerInferenceSettings.ts | 15 + .../schemas/BalancerTextGenerationSettings.ts | 18 + .../src/schemas/DecideParams.ts | 12 + .../src/schemas/DecisionQuestion.ts | 11 + .../src/schemas/DecisionSettings.ts | 11 + .../src/schemas/EmbeddingParameters.ts | 19 + .../schemas/GenerateEmbeddingBatchParams.ts | 2 +- .../src/schemas/InferenceMode.ts | 11 + .../src/schemas/InferenceNotification.ts | 9 - .../schemas/InferenceServiceDecideResponse.ts | 172 + .../InferenceServiceGenerateTokensResponse.ts | 8 +- .../src/schemas/ModelRuntimeParameters.ts | 38 + .../src/schemas/MultimodalSettings.ts | 12 + ...nceParameters.ts => SamplingParameters.ts} | 43 +- .../tests/BalancerAddresses.ts | 1 + .../DesiredStateServesAnotherModeError.ts | 6 + .../tests/SpawnedBalancer.ts | 18 +- ...assertFetchJsonReadsStoredDesiredState.ts} | 19 +- ...ADesiredStateThatOffloadsAllLayers.test.ts | 14 +- ...dsTheDesiredStateTheBalancerStores.test.ts | 17 +- ...ientReportsDisabledTokenGeneration.test.ts | 71 - ...rDesiredStateRejectsAnInvalidState.test.ts | 6 +- ...ancerReportsAnExitBeforeAnnouncing.test.ts | 5 +- ...sTheDesiredStateOfItsInferenceMode.test.ts | 29 + ...ortsAProcessThatIgnoresTermination.test.ts | 1 + ...iversADecisionTimeoutWithoutAgents.test.ts | 44 + ...sRefusesADesiredStateOfAnotherMode.test.ts | 30 + .../tests/qwen3DesiredState.ts | 35 +- .../tests/unit/schemas/Agent.test.ts | 6 +- .../tests/unit/schemas/AgentsResponse.test.ts | 2 +- .../schemas/InferenceNotification.test.ts | 18 - .../InferenceServiceDecideResponse.test.ts | 107 + ...renceServiceGenerateTokensResponse.test.ts | 2 +- .../schemas/ModelRuntimeParameters.test.ts | 31 + ...ers.test.ts => SamplingParameters.test.ts} | 33 +- .../tests/waitForAgentReady.ts | 7 +- .../tests/withSpawnedBalancer.ts | 4 + .../tests/withTextGenerationSettings.ts | 24 + paddler_client_python/LICENSE | 201 - paddler_client_python/README.md | 95 - .../paddler_client/__init__.py | 4 - .../agent_controller_pool_snapshot.py | 7 - .../agent_controller_snapshot.py | 10 - .../paddler_client/agent_desired_model.py | 95 - .../paddler_client/agent_issue.py | 39 - .../agent_state_application_status.py | 9 - .../paddler_client/agent_status.py | 17 - .../paddler_client/balancer_desired_state.py | 17 - .../buffered_request_manager_snapshot.py | 5 - .../paddler_client/chat_template.py | 5 - .../paddler_client/client_inference.py | 162 - .../paddler_client/client_management.py | 161 - ...ntinue_from_conversation_history_params.py | 25 - .../continue_from_raw_prompt_params.py | 9 - .../paddler_client/conversation_message.py | 12 - .../conversation_message_content_part.py | 22 - .../paddler_client/embedding.py | 15 - .../embedding_input_document.py | 6 - .../embedding_normalization_method.py | 63 - paddler_client_python/paddler_client/error.py | 205 - .../paddler_client/format_api_url.py | 8 - .../generate_embedding_batch_params.py | 11 - .../paddler_client/generation_finish.py | 8 - .../paddler_client/grammar_constraint.py | 19 - .../huggingface_model_reference.py | 7 - .../paddler_client/image_url.py | 5 - .../paddler_client/inference_message.py | 539 --- .../paddler_client/inference_parameters.py | 53 - .../inference_socket_connection.py | 83 - .../paddler_client/inference_socket_pool.py | 62 - .../paddler_client/inference_socket_url.py | 29 - .../paddler_client/kv_cache_dtype.py | 13 - .../paddler_client/model_download_status.py | 36 - .../paddler_client/model_metadata.py | 5 - .../ndjson_inference_messages.py | 22 - .../oversized_embedding_document_details.py | 19 - .../paddler_client/oversized_media_details.py | 17 - .../oversized_prompt_details.py | 17 - .../paddler_client/parsed_tool_call.py | 29 - .../paddler_client/pending_requests.py | 83 - .../paddler_client/pooling_type.py | 10 - .../raise_for_streaming_error.py | 18 - .../paddler_client/raw_tool_call_tokens.py | 17 - .../paddler_client/response_stream.py | 35 - .../paddler_client/stream_ndjson.py | 22 - .../paddler_client/stream_sse.py | 22 - paddler_client_python/paddler_client/tool.py | 18 - .../paddler_client/tool_call_arguments.py | 27 - .../unparsed_message_request_id.py | 16 - .../paddler_client/url_model_reference.py | 5 - .../validated_parameters_schema.py | 15 - paddler_client_python/poetry.lock | 904 ---- paddler_client_python/pyproject.toml | 89 - .../tests/cluster/conftest.py | 28 - ...ence_over_http_times_out_without_agents.py | 34 - .../test_inference_reports_its_health.py | 10 - ...nference_reports_unsuccessful_responses.py | 69 - ...ers_consecutive_requests_without_agents.py | 28 - ...ection_rejects_requests_after_it_closes.py | 21 - ...reconnects_after_the_balancer_closes_it.py | 38 - ...ocket_reports_disabled_token_generation.py | 42 - ...tream_ends_when_the_balancer_shuts_down.py | 23 - ...st_management_exposes_prometheus_gauges.py | 24 - ...test_management_lists_registered_agents.py | 31 - .../test_management_reports_its_health.py | 10 - ...reports_no_buffered_requests_while_idle.py | 12 - ...plate_override_for_an_agent_without_one.py | 14 - ...l_metadata_for_an_agent_without_a_model.py | 14 - ...chat_template_override_an_agent_applies.py | 24 - ...nagement_reports_unsuccessful_responses.py | 49 - ...ment_serves_the_desired_state_it_stores.py | 18 - ...test_management_streams_agent_snapshots.py | 18 - ...ment_streams_buffered_request_snapshots.py | 17 - paddler_client_python/tests/llm/__init__.py | 0 paddler_client_python/tests/llm/conftest.py | 27 - ...sation_history_streams_tokens_over_http.py | 35 - ...treams_tokens_over_the_inference_socket.py | 35 - ...with_an_invalid_tool_schema_is_rejected.py | 46 - ...orts_documents_exceeding_the_batch_size.py | 65 - ...atch_returns_one_embedding_per_document.py | 42 - ...ts_the_model_metadata_of_a_loaded_model.py | 14 - ...treams_tokens_over_the_inference_socket.py | 30 - ...the_inference_socket_streams_until_done.py | 65 - paddler_client_python/tests/unit/__init__.py | 0 .../test_agent_controller_pool_snapshot.py | 26 - .../unit/test_agent_controller_snapshot.py | 27 - .../tests/unit/test_agent_desired_model.py | 143 - .../tests/unit/test_agent_issue.py | 60 - .../test_agent_state_application_status.py | 13 - .../tests/unit/test_balancer_desired_state.py | 23 - .../test_buffered_request_manager_snapshot.py | 11 - .../tests/unit/test_chat_template.py | 8 - ...ntinue_from_conversation_history_params.py | 42 - ...rejects_tool_call_parsing_without_tools.py | 27 - .../test_continue_from_raw_prompt_params.py | 33 - .../tests/unit/test_conversation_message.py | 39 - .../tests/unit/test_embedding.py | 32 - .../unit/test_embedding_input_document.py | 8 - .../test_embedding_normalization_method.py | 95 - .../tests/unit/test_format_api_url.py | 27 - .../test_generate_embedding_batch_params.py | 21 - .../tests/unit/test_grammar_constraint.py | 30 - .../tests/unit/test_inference_message.py | 750 --- .../tests/unit/test_inference_parameters.py | 58 - ...rence_socket_pool_rejects_an_empty_pool.py | 11 - .../tests/unit/test_inference_socket_url.py | 45 - .../tests/unit/test_kv_cache_dtype.py | 13 - .../tests/unit/test_model_download_status.py | 67 - .../tests/unit/test_model_metadata.py | 16 - ...son_inference_messages_skip_blank_lines.py | 26 - .../tests/unit/test_parsed_tool_call.py | 42 - ...uests_delivers_a_message_to_its_request.py | 25 - ...quests_drops_every_request_when_drained.py | 22 - ..._a_request_whose_error_cannot_be_parsed.py | 19 - ...request_whose_response_cannot_be_parsed.py | 29 - ...gnores_a_message_for_an_unknown_request.py | 24 - ..._malformed_messages_it_cannot_attribute.py | 26 - ...first_of_two_registrations_of_a_request.py | 22 - .../tests/unit/test_pooling_type.py | 10 - paddler_client_python/tests/unit/test_tool.py | 58 - .../tests/unit/test_tool_call_arguments.py | 31 - .../tests/unit/validation_error_cause.py | 7 - paddler_gui/Cargo.toml | 10 +- paddler_gui/src/agent_runner_messages.rs | 4 +- paddler_gui/src/agent_running_data.rs | 7 +- paddler_gui/src/app.rs | 23 +- paddler_gui/src/balancer_runner_messages.rs | 4 +- paddler_gui/src/current_screen.rs | 2 +- paddler_gui/src/home_data.rs | 9 +- paddler_gui/src/inference_mode_choice.rs | 44 + paddler_gui/src/join_balancer_form_action.rs | 4 +- paddler_gui/src/join_balancer_form_data.rs | 14 +- paddler_gui/src/lib.rs | 2 + paddler_gui/src/message.rs | 7 +- paddler_gui/src/model_preset.rs | 90 +- paddler_gui/src/runner_failure.rs | 19 + paddler_gui/src/running_balancer_data.rs | 8 +- paddler_gui/src/running_balancer_snapshot.rs | 7 +- paddler_gui/src/screen.rs | 30 +- paddler_gui/src/start_balancer_form_action.rs | 4 +- paddler_gui/src/start_balancer_form_data.rs | 127 +- .../src/start_balancer_form_message.rs | 2 + paddler_gui/src/ui/view_agent_card.rs | 3 +- paddler_gui/src/ui/view_home.rs | 4 +- .../src/ui/view_start_balancer_form.rs | 20 +- paddler_gui_tests/Cargo.toml | 4 +- .../src/loopback_balancer_addresses.rs | 1 + ...ts_address_shows_why_on_the_home_screen.rs | 9 +- ...le_running_shows_why_on_the_home_screen.rs | 12 +- ...iled_agent_shows_why_on_the_home_screen.rs | 4 +- .../a_running_cluster_lists_its_agents.rs | 13 +- ...shows_its_configured_and_applied_models.rs | 5 +- ...g_cluster_without_agents_waits_for_them.rs | 7 +- ...elling_an_agent_reports_that_it_stopped.rs | 6 +- .../home_screen_offers_to_join_a_cluster.rs | 2 +- .../home_screen_offers_to_start_a_cluster.rs | 2 +- ...ws_the_error_that_brought_the_user_back.rs | 9 +- .../src/embedding_parameters.rs | 24 + paddler_inference_parameters/src/lib.rs | 7 +- .../src/model_runtime_parameters.rs | 140 + .../src/raw_inference_parameters.rs | 30 - .../src/raw_model_runtime_parameters.rs | 16 + .../src/raw_sampling_parameters.rs | 14 + ...e_parameters.rs => sampling_parameters.rs} | 160 +- .../.gitignore | 0 .../paddler_kev_converter}/__init__.py | 0 .../paddler_kev_converter/command_line.py | 83 + .../convertible_kev_checkpoint.py | 99 + .../paddler_kev_converter/error.py | 48 + .../malformed_pointer_head.py | 78 + .../paddler_kev_converter/parity_reference.py | 114 + .../paddler_kev_converter/pointer_head.py | 60 + .../synthetic_pointer_head.py | 33 + .../tokenizer_reference.py | 45 + .../typesafe_reference.py | 139 + paddler_kev_converter_python/poetry.lock | 4045 +++++++++++++++++ paddler_kev_converter_python/pyproject.toml | 75 + .../stubs/kev/__init__.pyi | 0 .../stubs/kev/api.pyi | 12 + .../stubs/kev/checkpoint.pyi | 25 + .../stubs/kev/model.pyi | 17 + .../tests}/__init__.py | 0 .../tests/conftest.py | 55 + ...nt_on_an_attention_only_base_is_refused.py | 24 + ...t_trained_on_a_bf16_backbone_is_refused.py | 20 + ...nt_training_token_embeddings_is_refused.py | 18 + ...est_a_full_weight_checkpoint_is_refused.py | 16 + ..._option_isolating_checkpoint_is_refused.py | 16 + ...st_converting_copies_the_base_tokenizer.py | 24 + ...ged_backbone_as_a_causal_language_model.py | 21 + ...ites_the_pointer_head_of_the_checkpoint.py | 35 + ...formed_pointer_heads_carry_their_defect.py | 62 + ...ynthetic_pointer_heads_are_reproducible.py | 26 + ...rendered_records_with_kev_probabilities.py | 43 + ...ference_pairs_texts_with_base_token_ids.py | 31 + ..._reference_renders_and_answers_like_kev.py | 26 + paddler_llama_cpp_converter_python/.gitignore | 1 + .../poetry.lock | 1705 +++++++ .../pyproject.toml | 30 + paddler_messaging/src/agent_desired_state.rs | 28 +- .../src/agent_inference_settings.rs | 112 + paddler_messaging/src/agent_issue.rs | 7 + .../src/agent_issue_params/mod.rs | 4 + ...ecture_unsupported_for_decisions_params.rs | 11 + .../pointer_head_incompatibility.rs | 14 + ...ter_head_incompatible_with_model_params.rs | 12 + ...slots_insufficient_for_decisions_params.rs | 9 + paddler_messaging/src/agent_runtime_status.rs | 65 + paddler_messaging/src/agent_status.rs | 3 +- .../src/agent_text_generation_settings.rs | 15 + paddler_messaging/src/api_path.rs | 1 + paddler_messaging/src/atomic_value.rs | 4 - .../src/balancer_desired_state.rs | 24 +- .../src/balancer_inference_settings.rs | 58 + .../src/balancer_text_generation_settings.rs | 82 + paddler_messaging/src/chat_template_source.rs | 12 + paddler_messaging/src/decision_answer.rs | 9 + paddler_messaging/src/decision_question.rs | 10 + paddler_messaging/src/decision_result.rs | 54 + paddler_messaging/src/decision_settings.rs | 10 + .../notification.rs => decision_summary.rs} | 6 +- paddler_messaging/src/embedding_result.rs | 72 +- .../src/generated_token_result.rs | 6 +- .../src/inference_client/message.rs | 2 - paddler_messaging/src/inference_client/mod.rs | 1 - .../src/inference_client/response.rs | 82 +- paddler_messaging/src/inference_mode.rs | 10 + paddler_messaging/src/lib.rs | 14 + .../src/management_socket/agent/request.rs | 8 + .../src/management_socket/agent/response.rs | 16 +- .../src/model_download_status.rs | 20 + paddler_messaging/src/model_metadata.rs | 2 +- paddler_messaging/src/multimodal_settings.rs | 24 + .../src/oversized_decision_details.rs | 9 + .../src/request_params/decide_params/mod.rs | 14 + .../decide_params/raw_decide_params.rs | 131 + paddler_messaging/src/request_params/mod.rs | 1 + .../src/request_params_validation_error.rs | 6 + paddler_openai_client_python_test/README.md | 14 +- paddler_openai_client_python_test/poetry.lock | 410 +- .../pyproject.toml | 5 +- .../tests/conftest.py | 19 +- ...tions_return_message_content_and_usage.py} | 2 +- ..._accumulates_content_and_reports_usage.py} | 2 +- ...responses_return_output_text_and_usage.py} | 2 +- ..._completed_and_accumulates_output_text.py} | 2 +- paddler_openai_translation/Cargo.toml | 29 + .../src}/arguments_to_tool_call_string.rs | 0 .../src}/assistant_role.rs | 0 .../src}/chat_completion.rs | 32 +- .../src}/chat_completion_chunk.rs | 36 +- .../src}/chat_completion_chunk_choice.rs | 16 +- .../src/chat_completion_chunk_payload.rs | 8 + .../src/chat_completion_delivery.rs | 7 + .../src}/chat_completion_finish_reason.rs | 0 .../src/chat_completion_header.rs | 145 + .../src/chat_completion_message.rs | 8 +- .../src/chat_completion_request.rs | 107 +- .../src/chat_completion_stream.rs | 305 ++ .../src/chat_completion_stream_options.rs | 2 +- .../src/chat_completion_stream_params.rs | 7 + .../src/chat_completion_tool.rs | 14 +- .../src}/chat_completion_tool_call.rs | 2 +- .../src/chat_completion_usage.rs | 4 +- .../src/completes_generation.rs | 16 + .../src}/content_part_event.rs | 2 +- .../src/default_max_tokens.rs | 3 + .../function_call_arguments_delta_event.rs | 0 .../function_call_arguments_done_event.rs | 0 .../src/function_definition.rs | 14 +- .../src/generated_output.rs | 20 + .../src/generated_output_part.rs | 8 + .../src/generation_event.rs | 370 ++ .../src/generation_failure.rs | 7 + .../src/generation_failure_cause.rs | 6 + paddler_openai_translation/src/lib.rs | 67 + .../src}/open_item.rs | 0 .../src/openai_translation_error.rs | 16 + .../src}/output_item_event.rs | 2 +- .../src/reasoning_effort.rs | 8 +- .../src}/response_snapshot_event.rs | 2 +- .../src}/responses_content_part.rs | 0 .../src/responses_delivery.rs | 7 + .../src/responses_function_call_item.rs | 2 +- .../responses_function_call_output_item.rs | 8 + .../src/responses_function_output.rs | 51 + .../src/responses_function_output_part.rs | 2 +- .../src/responses_input.rs | 12 +- .../src/responses_input_content_part.rs | 10 +- .../src/responses_input_item.rs | 45 + .../src}/responses_item_status.rs | 0 .../src/responses_message_content.rs | 10 +- .../src/responses_message_item.rs | 8 +- .../src}/responses_output_item.rs | 10 +- .../src}/responses_output_item_kind.rs | 0 .../src/responses_reasoning.rs | 15 + .../src}/responses_reasoning_part.rs | 0 .../src/responses_request.rs | 226 +- .../src}/responses_response.rs | 10 +- .../src/responses_response_header.rs | 253 ++ .../src}/responses_response_progress.rs | 4 +- .../src}/responses_response_status.rs | 0 .../src/responses_stream.rs | 586 +++ .../src}/responses_stream_event.rs | 16 +- .../src/responses_tagged_item.rs | 16 + .../src/responses_text_format.rs | 2 +- .../src/responses_text_param.rs | 12 +- .../src/responses_tool.rs | 10 + .../src}/responses_usage.rs | 0 .../src}/text_delta_event.rs | 0 .../src}/text_done_event.rs | 0 .../src/timestamp_from.rs | 36 + .../src/tool_parameters_schema.rs | 10 +- .../src/translated_chat_completion_request.rs | 10 + .../src/translated_responses_request.rs | 11 +- ...t_with_an_unsatisfiable_tool_is_refused.rs | 36 + ...slated_before_the_unix_epoch_is_refused.rs | 38 + ...er_of_another_inference_mode_is_refused.rs | 26 + ..._requests_translate_into_their_delivery.rs | 90 + paddler_openai_translation/tests/main.rs | 5 + ...eport_the_sampling_parameters_in_effect.rs | 80 + paddler_service_thread/Cargo.toml | 24 + paddler_service_thread/src/lib.rs | 3 + .../src/run_service_manager.rs | 0 .../src/service_thread.rs | 29 +- .../src/service_thread_error.rs | 13 + paddler_state_database/Cargo.toml | 1 - ...ure_requested_state_serves_cluster_mode.rs | 54 + ...ensure_stored_state_serves_cluster_mode.rs | 57 + paddler_state_database/src/file.rs | 266 +- paddler_state_database/src/lib.rs | 3 + paddler_state_database/src/memory.rs | 53 +- paddler_state_database/src/schema.rs | 31 +- .../src/schema_version_header.rs | 7 + .../src/state_database_error.rs | 18 + .../src/state_database_schema_version.rs | 7 +- .../src/state_database_type.rs | 7 +- paddler_test_cluster_harness/Cargo.toml | 1 + paddler_test_cluster_harness/src/cluster.rs | 160 +- .../src/cluster_desired_state.rs | 19 + .../src/cluster_harness_error.rs | 9 + .../src/cluster_params.rs | 8 +- .../src/collect_decision_results.rs | 44 + .../src/collect_embedding_results.rs | 27 +- .../src/collect_generated_tokens.rs | 2 +- .../src/collected_decision_results.rs | 7 + .../src/collected_embedding_results.rs | 5 +- .../src/embedding_cluster_params.rs | 23 +- paddler_test_cluster_harness/src/lib.rs | 7 +- .../src/model_card/mod.rs | 66 +- .../src/openai_api_client.rs | 14 +- .../src/raw_agent_socket.rs | 10 +- .../src/raw_inference_socket.rs | 17 +- .../src/running_balancer.rs | 10 + .../src/snapshots_watcher.rs | 3 +- .../src/typesafe_api_client.rs | 68 + .../src/typesafe_api_response.rs | 9 + paddler_test_cluster_python/.gitignore | 21 - .../paddler_test_cluster/__init__.py | 0 .../paddler_test_cluster/agent_spec.py | 7 - .../paddler_test_cluster/all_gpu_layers.py | 1 - .../balancer_addresses.py | 27 - .../paddler_test_cluster/error.py | 47 - .../nomic_embed_text_v1_5_desired_state.py | 21 - .../paddler_binary_path.py | 7 - .../paddler_test_cluster/paddler_cluster.py | 57 - .../paddler_test_cluster/py.typed | 0 .../qwen3_0_6b_desired_state.py | 26 - .../paddler_test_cluster/spawned_agent.py | 21 - .../paddler_test_cluster/spawned_balancer.py | 50 - .../paddler_test_cluster/spawned_process.py | 40 - .../wait_for_agent_ready.py | 33 - paddler_test_cluster_python/tests/__init__.py | 0 .../tests/cluster/__init__.py | 0 ...gent_reporting_issues_fails_the_cluster.py | 29 - ...am_closing_before_readiness_is_reported.py | 21 - ...er_serves_on_the_addresses_it_announces.py | 25 - ...hat_exits_before_announcing_is_reported.py | 13 - ...er_without_a_model_registers_its_agents.py | 18 - ..._that_exited_with_a_failure_is_reported.py | 24 - ...ss_that_ignores_termination_is_reported.py | 18 - .../tests/llm/__init__.py | 0 ...a_model_waits_until_every_slot_loads_it.py | 29 - .../tests/unit/__init__.py | 0 ..._compat_openai_url_requires_the_service.py | 16 - paddler_tests/Cargo.toml | 21 +- .../src/bin/run_with_test_cluster.rs | 90 + paddler_tests/src/decision_cluster_params.rs | 29 + .../decision_cluster_without_agents_params.rs | 13 + ...sired_state_with_chat_template_override.rs | 29 + .../desired_state_with_halved_image_resize.rs | 32 +- ...esired_state_with_multimodal_projection.rs | 32 + paddler_tests/src/in_process_agent.rs | 2 +- paddler_tests/src/in_process_agent_spawner.rs | 8 +- paddler_tests/src/in_process_balancer.rs | 2 +- paddler_tests/src/kev_0_8b_desired_state.rs | 28 + paddler_tests/src/kev_parity_record.rs | 11 + paddler_tests/src/lib.rs | 14 +- .../load_qwen3_after_request_is_buffered.rs | 39 - paddler_tests/src/many_question_decision.rs | 23 + ...sired_state_with_chat_template_override.rs | 16 +- .../qwen3_desired_state_with_embeddings.rs | 17 - paddler_tests/src/sample_decision.rs | 32 + paddler_tests/src/serving_agent_status.rs | 19 + paddler_tests/src/start_cluster.rs | 31 +- ...uster_with_deepseek_r1_distill_llama_8b.rs | 5 +- .../src/start_cluster_with_gemma_3.rs | 3 +- ...ter_with_gemma_3_and_mmproj_and_n_batch.rs | 11 +- .../src/start_cluster_with_gemma_4.rs | 3 +- .../start_cluster_with_gemma_4_and_mmproj.rs | 5 +- .../src/start_cluster_with_ministral_3.rs | 5 +- ...art_cluster_with_ministral_3_and_mmproj.rs | 5 +- .../src/start_cluster_with_qwen2_5_vl.rs | 5 +- paddler_tests/src/start_cluster_with_qwen3.rs | 3 +- .../src/start_cluster_with_qwen3_5.rs | 4 +- ...t_cluster_with_qwen3_5_and_context_size.rs | 20 +- ...art_cluster_with_qwen3_and_context_size.rs | 11 +- .../src/start_cluster_with_smolvlm2.rs | 3 +- ..._cluster_with_smolvlm2_and_context_size.rs | 11 +- ...start_cluster_with_smolvlm2_and_n_batch.rs | 11 +- .../src/start_cluster_without_model.rs | 29 - paddler_tests/src/start_decision_cluster.rs | 43 + ...single_agent_cluster_with_desired_state.rs | 3 +- paddler_tests/src/test_cluster_preset.rs | 37 + ...tion_cluster_serves_no_embedding_route.rs} | 21 +- ..._embedding_batch_larger_than_slot_count.rs | 8 +- ...istribution_independent_of_context_size.rs | 9 +- ...eturns_one_embedding_per_input_document.rs | 8 +- ...h_all_oversized_documents_reports_error.rs | 9 +- ...mension_across_inputs_of_varying_length.rs | 6 +- ...ent_longer_than_the_default_micro_batch.rs | 7 +- ...eration_that_fills_its_sequence_context.rs | 11 +- ...oncurrent_embedding_requests_per_client.rs | 6 +- ...rence_when_the_desired_model_is_missing.rs | 7 +- ...l2_normalized_embeddings_have_unit_norm.rs | 6 +- ...odal_conversation_containing_a_nul_byte.rs | 62 + ...versation_after_switching_to_embeddings.rs | 57 - ...edding_batch_after_disabling_embeddings.rs | 46 - ...aw_prompt_after_switching_to_embeddings.rs | 47 - ..._embedding_document_containing_nul_byte.rs | 17 +- ...s_raw_prompt_exceeding_sequence_context.rs | 11 +- ...ejects_structurally_invalid_tool_schema.rs | 13 +- ...a_model_architecture_that_cannot_decide.rs | 35 + ...t_reports_a_pointer_head_it_cannot_load.rs | 32 + ...s_a_pointer_head_of_another_hidden_size.rs | 41 + ...hat_template_error_when_rendering_fails.rs | 19 +- ...ompt_exceeding_a_non_causal_micro_batch.rs | 8 +- ...grammar_syntax_error_for_malformed_gbnf.rs | 38 +- ..._oversized_document_and_embeds_the_rest.rs | 11 +- ..._delimiters_that_are_not_control_tokens.rs | 38 + ...tart_for_a_batch_smaller_than_its_slots.rs | 67 + ...s_slot_cannot_start_for_excessive_slots.rs | 14 +- ...ent_reports_too_few_slots_for_decisions.rs | 34 + ...nt_reports_tool_call_validation_failure.rs | 13 +- ...ical_embeddings_for_identical_documents.rs | 6 +- ...ms_normalized_embeddings_when_requested.rs | 6 +- ..._unnormalized_embeddings_when_requested.rs | 6 +- ...rves_embeddings_without_a_chat_template.rs | 23 +- ...a_batch_too_small_to_warm_up_every_slot.rs | 37 + ...dding_batch_when_the_client_disconnects.rs | 80 + ...ster_serves_no_token_generation_routes.rs} | 34 +- ...etes_buffered_request_after_agent_joins.rs | 6 +- ...ce_socket_recovers_from_embeddings_mode.rs | 134 - ...r_memory_storage_persists_desired_state.rs | 10 +- ...s_chat_template_override_across_restart.rs | 38 +- ...r_persists_desired_state_across_restart.rs | 12 +- ...sts_huggingface_mmproj_in_desired_state.rs | 26 +- ...ists_local_mmproj_path_in_desired_state.rs | 26 +- ...lancer_persists_model_switch_in_storage.rs | 15 +- ...cer_persists_url_model_in_desired_state.rs | 12 +- ...sired_state_with_an_ambiguous_parameter.rs | 24 +- ...egistration_under_a_registered_agent_id.rs | 6 +- ...ate_from_a_socket_that_did_not_register.rs | 6 +- ...late_does_not_compile_for_invalid_jinja.rs | 22 +- ...compile_recovers_when_template_replaced.rs | 38 +- ...r_reports_download_server_denied_access.rs | 3 +- ...alancer_reports_download_server_errored.rs | 3 +- ..._reports_download_server_is_unreachable.rs | 3 +- ...ancer_reports_download_url_is_malformed.rs | 3 +- ...eports_huggingface_model_does_not_exist.rs | 3 +- ...model_cannot_be_loaded_for_corrupt_file.rs | 3 +- ...model_cannot_be_loaded_for_invalid_gguf.rs | 60 +- ...cer_reports_model_does_not_exist_at_url.rs | 3 +- ...ancer_reports_model_file_does_not_exist.rs | 3 +- ...ction_cannot_be_loaded_for_invalid_file.rs | 17 +- ...ction_cannot_be_loaded_for_missing_file.rs | 17 +- ...est_id_across_a_chunked_embedding_batch.rs | 7 +- ..._find_chat_template_for_embedding_model.rs | 3 +- ...rns_503_when_request_buffering_disabled.rs | 3 - ...504_when_inference_item_timeout_is_zero.rs | 6 +- ...ncurrent_request_on_a_single_slot_agent.rs | 3 +- ...alancer_serves_inference_over_websocket.rs | 5 +- ...est_after_agent_with_capacity_registers.rs | 11 +- ...ent_connections_that_share_a_request_id.rs | 3 +- ..._drains_in_flight_inference_before_swap.rs | 12 +- ...ate_override_applied_to_embedding_model.rs | 6 +- ...emplate_override_replaces_model_builtin.rs | 29 +- ..._template_swaps_between_inference_calls.rs | 12 +- ...e_for_an_embedding_batch_without_agents.rs | 13 +- ...gent_that_reports_issues_while_starting.rs | 8 +- ...nce_that_fills_its_share_of_the_context.rs | 11 +- ...kens_with_distinct_k_and_v_cache_dtypes.rs | 26 +- ...rates_tokens_with_partial_layer_offload.rs | 22 +- ..._distinct_k_and_v_cache_dtypes_on_metal.rs | 26 +- ...distinct_outputs_for_concurrent_prompts.rs | 3 +- ...ects_embedding_during_active_generation.rs | 86 - ...ects_second_request_when_only_slot_busy.rs | 3 +- ...s_down_cleanly_with_decisions_in_flight.rs | 69 + ...qual_separate_single_question_decisions.rs | 46 + ...ecision_answers_every_question_in_order.rs | 49 + ...ing_mid_decision_releases_its_sequences.rs | 57 + ...ision_exceeding_the_context_is_rejected.rs | 54 + ..._in_full_when_the_desired_state_changes.rs | 75 + ...ision_input_with_a_nul_byte_is_rejected.rs | 63 + ...rows_longer_than_the_batch_are_answered.rs | 66 + ...text_matches_the_hugging_face_tokenizer.rs | 75 + ..._questions_is_rejected_with_bad_request.rs | 46 + ...ns_are_identical_alone_and_concurrently.rs | 60 + ...on_history_generation_before_max_tokens.rs | 3 +- ..._decision_client_releases_its_sequences.rs | 67 + ...s_requests_after_the_balancer_closes_it.rs | 3 - ...only_when_token_generation_mode_changes.rs | 99 - ...ancellation_serves_only_the_freed_slots.rs | 3 +- .../kev_decisions_match_the_fp32_reference.rs | 104 + paddler_tests/tests/main.rs | 73 +- ...rns_balancer_desired_state_that_was_put.rs | 20 +- ...e_applicable_state_of_the_desired_state.rs | 12 +- ..._subscribers_receive_slot_usage_changes.rs | 3 +- ...ing_emits_tool_calls_for_function_tool.rs} | 2 +- ...d_client_releases_the_buffered_request.rs} | 2 +- ..._completion_non_streaming_returns_text.rs} | 2 +- ...completion_non_streaming_returns_usage.rs} | 2 +- ...on_non_streaming_usage_with_tool_calls.rs} | 2 +- ...alancer_failures_with_their_http_status.rs | 160 + ...ing_emits_tool_calls_for_function_tool.rs} | 2 +- ...n_streaming_emits_usage_when_requested.rs} | 2 +- ...d_client_releases_the_buffered_request.rs} | 2 +- ...reaming_omits_usage_when_not_requested.rs} | 2 +- ...at_completion_streaming_returns_chunks.rs} | 2 +- ...treaming_usage_breakdown_with_thinking.rs} | 2 +- ...ort_none_generates_no_reasoning_tokens.rs} | 2 +- ...urns_ok.rs => openai_health_returns_ok.rs} | 5 +- ...ted_while_the_cluster_serves_embeddings.rs | 55 - ...d_client_releases_the_buffered_request.rs} | 2 +- ...s_non_streaming_returns_text_and_usage.rs} | 2 +- ...s_browsers_send_and_read_openai_headers.rs | 73 + ...es_only_the_services_its_cluster_serves.rs | 22 + .../tests/typesafe_health_returns_ok.rs | 26 + ...dels_lists_the_model_the_cluster_serves.rs | 79 + ...browsers_send_and_read_typesafe_headers.rs | 74 + ...em_one_answers_questions_in_their_order.rs | 140 + ...tem_one_answers_with_the_decision_model.rs | 74 + ...cts_malformed_requests_as_unprocessable.rs | 50 + ...alancer_failures_with_their_http_status.rs | 96 + .../.gitignore | 5 + paddler_typesafe_client_python_test/README.md | 20 + .../poetry.lock | 498 +- .../pyproject.toml | 37 +- .../tests/conftest.py | 13 + ...st_models_list_returns_the_served_model.py | 16 + ..._system_one_answers_every_question_type.py | 30 + ...m_one_rejects_a_choice_without_criteria.py | 14 + paddler_typesafe_translation/Cargo.toml | 23 + .../src/choice_confidence.rs | 20 + paddler_typesafe_translation/src/lib.rs | 15 + .../src/normalized_probabilities.rs | 34 + .../src/python_float_repr.rs | 96 + .../src/render_json_content.rs | 51 + .../src/round_probability.rs | 17 + .../src/score_confidence.rs | 34 + .../src/system_one_answer.rs | 52 + .../src/system_one_answer_layout.rs | 102 + .../src/system_one_question.rs | 34 + .../src/system_one_request.rs | 152 + .../src/system_one_response.rs | 13 + .../src/system_one_usage.rs | 7 + .../src/translated_system_one_question.rs | 8 + .../src/translated_system_one_request.rs | 61 + .../src/typesafe_translation_error.rs | 48 + ...lformed_question_is_refused_with_its_id.rs | 24 + ...null_criteria_describes_neither_outcome.rs | 20 + ...ne_request_without_questions_is_refused.rs | 18 + ...at_do_not_fit_the_questions_are_refused.rs | 49 + paddler_typesafe_translation/tests/main.rs | 6 + ...ith_too_many_or_no_criteria_are_refused.rs | 40 + ...tion_matches_the_kev_typesafe_reference.rs | 99 + resources/ts/components/AgentIssues.tsx | 76 + .../ts/components/AgentListAgentStatus.tsx | 4 +- .../BalancerDesiredStateContextProvider.tsx | 34 + resources/ts/components/ChangeModelForm.tsx | 241 +- resources/ts/components/ChangeModelPage.tsx | 61 +- .../ChatTemplateContextProvider.tsx | 71 +- .../ts/components/ConversationPromptInput.tsx | 7 +- .../ConversationPromptInputImageButton.tsx | 6 - .../ConversationPromptInputThinkingToggle.tsx | 5 - resources/ts/components/DashboardPage.tsx | 20 +- .../components/EmbeddingParametersFields.tsx | 57 + resources/ts/components/Home.tsx | 25 +- .../InferenceParameterCacheDtype.tsx | 55 - .../components/InferenceParameterCheckbox.tsx | 42 - .../InferenceParameterPoolingType.tsx | 71 - .../InferenceParametersContextProvider.tsx | 57 - .../ModelRuntimeParametersFields.tsx | 79 + ...eterInput.tsx => ParameterNumberInput.tsx} | 20 +- resources/ts/components/ParameterSelect.tsx | 55 + resources/ts/components/PromptPage.tsx | 99 +- .../TextGenerationSettingsFields.tsx | 99 + ...TokenGenerationDisabledContextProvider.tsx | 57 - .../TokenGenerationDisabledNotice.module.css | 8 - .../TokenGenerationDisabledNotice.tsx | 29 - resources/ts/components/WorkbenchLayout.tsx | 14 +- .../contexts/BalancerDesiredStateContext.ts | 21 + .../ts/contexts/InferenceParametersContext.ts | 29 - .../contexts/PaddlerConfigurationContext.ts | 9 + .../TokenGenerationDisabledContext.ts | 12 - resources/ts/controller_dashboard.tsx | 6 + resources/ts/embeddingParametersOf.ts | 12 + resources/ts/inferenceParametersFormKeys.ts | 17 - resources/ts/textGenerationSettingsOf.ts | 12 + 1074 files changed, 28759 insertions(+), 17639 deletions(-) create mode 100644 .claude/rules/paddler-agent-decision.md create mode 100644 .claude/rules/paddler-agent-embeddings.md create mode 100644 .claude/rules/paddler-agent-pointer-head.md create mode 100644 .claude/rules/paddler-agent-runner.md create mode 100644 .claude/rules/paddler-agent-runtime.md create mode 100644 .claude/rules/paddler-agent-text-generation.md create mode 100644 .claude/rules/paddler-balancer-runner.md delete mode 100644 .claude/rules/paddler-bootstrap.md delete mode 100644 .claude/rules/paddler-client-python.md create mode 100644 .claude/rules/paddler-kev-converter-python.md create mode 100644 .claude/rules/paddler-openai-translation.md create mode 100644 .claude/rules/paddler-service-thread.md create mode 100644 .claude/rules/paddler-typesafe-translation.md create mode 100644 fixtures/kev_0_8b_parity_reference.json create mode 100644 fixtures/pointer_head_with_f16_tensors.gguf create mode 100644 fixtures/pointer_head_with_mismatched_projections.gguf create mode 100644 fixtures/pointer_head_with_zero_temperature.gguf create mode 100644 fixtures/pointer_head_without_delimiters.gguf create mode 100644 fixtures/pointer_head_without_temperature.gguf create mode 100644 fixtures/qwen3_5_0_8b_pointer_head_with_plain_text_delimiters.gguf create mode 100644 fixtures/qwen3_5_0_8b_synthetic_pointer_head.gguf create mode 100644 fixtures/qwen3_5_0_8b_synthetic_pointer_head_with_wrong_hidden_size.gguf create mode 100644 fixtures/qwen3_5_tokenizer_reference.json create mode 100644 fixtures/typesafe_reference.json delete mode 100644 paddler_agent/src/agent_applicable_model.rs create mode 100644 paddler_agent/src/agent_error.rs delete mode 100644 paddler_agent/src/chat_template_load_status.rs delete mode 100644 paddler_agent/src/continuous_batch_arbiter.rs delete mode 100644 paddler_agent/src/continuous_batch_arbiter_handle.rs delete mode 100644 paddler_agent/src/continuous_batch_arbiter_spawn_outcome.rs delete mode 100644 paddler_agent/src/continuous_batch_arbiter_state.rs delete mode 100644 paddler_agent/src/continuous_batch_preparation_request.rs delete mode 100644 paddler_agent/src/continuous_batch_request_preparer.rs delete mode 100644 paddler_agent/src/continuous_batch_scheduler_command.rs delete mode 100644 paddler_agent/src/embedding_batch_rejection.rs delete mode 100644 paddler_agent/src/forward_scheduler_command.rs delete mode 100644 paddler_agent/src/model_metadata_holder.rs create mode 100644 paddler_agent/src/pipeline_arbiter_state.rs create mode 100644 paddler_agent/src/pipeline_request.rs delete mode 100644 paddler_agent/src/prepared_embedding_batch_request.rs delete mode 100644 paddler_agent/src/prepared_generation_request.rs delete mode 100644 paddler_agent/src/require_embeddings_enabled.rs create mode 100644 paddler_agent/src/running_pipeline.rs delete mode 100644 paddler_agent/src/token_generation.rs create mode 100644 paddler_agent_decision/Cargo.toml create mode 100644 paddler_agent_decision/src/active_decision_request.rs create mode 100644 paddler_agent_decision/src/decision_admission.rs create mode 100644 paddler_agent_decision/src/decision_advance.rs create mode 100644 paddler_agent_decision/src/decision_capacity_ledger.rs create mode 100644 paddler_agent_decision/src/decision_decoder.rs create mode 100644 paddler_agent_decision/src/decision_delimiter_tokens.rs create mode 100644 paddler_agent_decision/src/decision_error.rs create mode 100644 paddler_agent_decision/src/decision_pipeline.rs create mode 100644 paddler_agent_decision/src/decision_question_row.rs create mode 100644 paddler_agent_decision/src/decision_request_phase.rs create mode 100644 paddler_agent_decision/src/decision_request_preparer.rs create mode 100644 paddler_agent_decision/src/decision_scheduler.rs create mode 100644 paddler_agent_decision/src/decision_scheduler_context.rs create mode 100644 paddler_agent_decision/src/decision_text_tokenizer.rs create mode 100644 paddler_agent_decision/src/decision_token_layout.rs create mode 100644 paddler_agent_decision/src/lib.rs create mode 100644 paddler_agent_decision/src/prepared_decision_request.rs create mode 100644 paddler_agent_decision/src/promised_decision_cells.rs create mode 100644 paddler_agent_decision/src/question_lane.rs create mode 100644 paddler_agent_decision/src/tokenized_decision_question.rs create mode 100644 paddler_agent_embeddings/Cargo.toml rename {paddler_agent => paddler_agent_embeddings}/src/agent_pooling_type.rs (100%) rename {paddler_agent => paddler_agent_embeddings}/src/converts_to_llama_pooling_type.rs (100%) rename {paddler_agent => paddler_agent_embeddings}/src/embedding_batch_preparer.rs (60%) rename paddler_agent/src/continuous_batch_embedding_processor.rs => paddler_agent_embeddings/src/embedding_batch_processor.rs (54%) create mode 100644 paddler_agent_embeddings/src/embedding_error.rs rename {paddler_agent => paddler_agent_embeddings}/src/embedding_input_tokenized.rs (100%) create mode 100644 paddler_agent_embeddings/src/embedding_pipeline.rs create mode 100644 paddler_agent_embeddings/src/embedding_scheduler.rs create mode 100644 paddler_agent_embeddings/src/embedding_scheduler_context.rs create mode 100644 paddler_agent_embeddings/src/lib.rs rename {paddler_agent => paddler_agent_embeddings}/src/normalization/l2.rs (100%) rename {paddler_agent => paddler_agent_embeddings}/src/normalization/mod.rs (100%) rename {paddler_agent => paddler_agent_embeddings}/src/normalization/normalize_embedding.rs (86%) rename {paddler_agent => paddler_agent_embeddings}/src/normalization/rms_norm.rs (100%) rename {paddler_agent => paddler_agent_embeddings}/src/plan_embedding_batches.rs (100%) create mode 100644 paddler_agent_embeddings/src/prepared_embedding_batch_request.rs create mode 100644 paddler_agent_embeddings/src/sequenced_embedding_input.rs create mode 100644 paddler_agent_pointer_head/Cargo.toml create mode 100644 paddler_agent_pointer_head/src/lib.rs create mode 100644 paddler_agent_pointer_head/src/pointer_head.rs create mode 100644 paddler_agent_pointer_head/src/pointer_head_delimiters.rs create mode 100644 paddler_agent_pointer_head/src/pointer_head_error.rs create mode 100644 paddler_agent_pointer_head/src/pointer_head_projection.rs create mode 100644 paddler_agent_pointer_head/tests/a_pointer_head_with_f16_tensors_is_refused.rs create mode 100644 paddler_agent_pointer_head/tests/a_pointer_head_with_mismatched_projections_is_refused.rs create mode 100644 paddler_agent_pointer_head/tests/a_pointer_head_with_zero_temperature_is_refused.rs create mode 100644 paddler_agent_pointer_head/tests/a_pointer_head_without_its_delimiters_is_refused.rs create mode 100644 paddler_agent_pointer_head/tests/a_pointer_head_without_its_temperature_is_refused.rs create mode 100644 paddler_agent_pointer_head/tests/an_unreadable_pointer_head_file_is_refused.rs create mode 100644 paddler_agent_pointer_head/tests/fixture_path.rs create mode 100644 paddler_agent_pointer_head/tests/loading_the_synthetic_pointer_head_reads_its_contract.rs create mode 100644 paddler_agent_pointer_head/tests/main.rs create mode 100644 paddler_agent_runner/Cargo.toml rename {paddler_bootstrap => paddler_agent_runner}/src/agent_runner.rs (77%) rename paddler_bootstrap/src/agent_bootstrap_config.rs => paddler_agent_runner/src/agent_runner_config.rs (78%) rename {paddler_bootstrap => paddler_agent_runner}/src/agent_runner_params.rs (66%) rename {paddler_bootstrap => paddler_agent_runner}/src/agent_service_bundle.rs (84%) create mode 100644 paddler_agent_runner/src/lib.rs rename {paddler_bootstrap => paddler_agent_runner}/src/llama_cpp_max_sequences.rs (100%) rename {paddler_bootstrap => paddler_agent_runner}/tests/agent_runner_completes_after_cancel_when_management_server_is_unresponsive.rs (84%) rename {paddler_bootstrap => paddler_agent_runner}/tests/agent_runner_completes_when_cancelled.rs (90%) rename {paddler_bootstrap => paddler_agent_runner}/tests/agent_runner_completes_when_its_parent_token_is_cancelled.rs (91%) rename {paddler_bootstrap => paddler_agent_runner}/tests/agent_runner_params_for_unreachable_balancer.rs (72%) rename {paddler_bootstrap => paddler_agent_runner}/tests/agent_runner_stops_when_dropped.rs (86%) create mode 100644 paddler_agent_runner/tests/main.rs create mode 100644 paddler_agent_runtime/Cargo.toml rename {paddler_agent => paddler_agent_runtime}/src/agent_kv_cache_dtype.rs (100%) rename {paddler_agent => paddler_agent_runtime}/src/agent_request.rs (82%) create mode 100644 paddler_agent_runtime/src/agent_runtime_error.rs create mode 100644 paddler_agent_runtime/src/await_scheduler_startup.rs rename {paddler_agent => paddler_agent_runtime}/src/converts_to_llama_kv_cache_dtype.rs (100%) create mode 100644 paddler_agent_runtime/src/describe_error_with_causes.rs rename paddler_agent/src/continuous_batch_arbiter_context.rs => paddler_agent_runtime/src/inference_runtime_context.rs (88%) create mode 100644 paddler_agent_runtime/src/inference_thread_count.rs rename {paddler_agent => paddler_agent_runtime}/src/join_scheduler_thread.rs (58%) create mode 100644 paddler_agent_runtime/src/join_scheduler_thread_in_background.rs create mode 100644 paddler_agent_runtime/src/lib.rs create mode 100644 paddler_agent_runtime/src/llama_context_settings.rs create mode 100644 paddler_agent_runtime/src/loaded_llama_model.rs create mode 100644 paddler_agent_runtime/src/loaded_model_metadata.rs create mode 100644 paddler_agent_runtime/src/model_metadata_holder.rs create mode 100644 paddler_agent_runtime/src/prepares_scheduler_command.rs rename {paddler_agent => paddler_agent_runtime}/src/receives_stop_request.rs (100%) rename {paddler_agent => paddler_agent_runtime}/src/rejection_description.rs (100%) create mode 100644 paddler_agent_runtime/src/scheduler_command.rs create mode 100644 paddler_agent_runtime/src/scheduler_handle.rs create mode 100644 paddler_agent_runtime/src/scheduler_message.rs create mode 100644 paddler_agent_runtime/src/scheduler_request_preparer.rs create mode 100644 paddler_agent_runtime/src/scheduler_spawn_outcome.rs rename {paddler_agent => paddler_agent_runtime}/src/send_result_or_warn.rs (100%) rename {paddler_agent => paddler_agent_runtime}/src/send_startup_signal.rs (100%) rename {paddler_agent => paddler_agent_runtime}/src/sequence_id_guard.rs (100%) rename {paddler_agent => paddler_agent_runtime}/src/sequence_id_pool.rs (100%) rename {paddler_agent => paddler_agent_runtime}/src/startup_signal_delivery.rs (100%) create mode 100644 paddler_agent_runtime/tests/a_cancelled_startup_joins_the_scheduler_thread.rs create mode 100644 paddler_agent_runtime/tests/a_scheduler_that_fails_before_readiness_reports_its_error.rs create mode 100644 paddler_agent_runtime/tests/a_scheduler_that_signals_readiness_starts.rs create mode 100644 paddler_agent_runtime/tests/commands_for_a_stopped_scheduler_are_rejected.rs create mode 100644 paddler_agent_runtime/tests/even_number_preparation.rs create mode 100644 paddler_agent_runtime/tests/inference_runtime_context_fixture.rs create mode 100644 paddler_agent_runtime/tests/joining_in_the_background_resumes_the_panic_of_the_scheduler_thread.rs create mode 100644 paddler_agent_runtime/tests/joining_resumes_the_panic_of_the_scheduler_thread.rs create mode 100644 paddler_agent_runtime/tests/loading_a_model_while_another_backend_runs_reports_that_it_cannot_be_loaded.rs create mode 100644 paddler_agent_runtime/tests/main.rs create mode 100644 paddler_agent_runtime/tests/number_command.rs create mode 100644 paddler_agent_runtime/tests/number_request_preparer.rs create mode 100644 paddler_agent_runtime/tests/prepared_requests_reach_the_scheduler_before_its_shutdown.rs create mode 100644 paddler_agent_runtime/tests/requests_the_preparation_rejects_never_reach_the_scheduler.rs create mode 100644 paddler_agent_runtime/tests/shutting_down_returns_the_scheduler_thread_result.rs rename {paddler_agent => paddler_agent_status}/src/slot_guard.rs (82%) create mode 100644 paddler_agent_text_generation/Cargo.toml rename {paddler_agent => paddler_agent_text_generation}/src/chat_prompt_render_request.rs (100%) rename {paddler_agent => paddler_agent_text_generation}/src/chat_prompt_renderer.rs (91%) rename {paddler_agent => paddler_agent_text_generation}/src/chat_template_renderer/mod.rs (100%) rename {paddler_agent => paddler_agent_text_generation}/src/chat_template_renderer/pyjinja_tojson.rs (100%) rename {paddler_agent => paddler_agent_text_generation}/src/chat_template_renderer/raise_exception.rs (100%) rename {paddler_agent => paddler_agent_text_generation}/src/continuous_batch_active_request.rs (92%) rename {paddler_agent => paddler_agent_text_generation}/src/continuous_batch_generation_step.rs (100%) rename {paddler_agent => paddler_agent_text_generation}/src/continuous_batch_request_phase.rs (100%) rename {paddler_agent => paddler_agent_text_generation}/src/continuous_batch_request_state.rs (100%) rename {paddler_agent => paddler_agent_text_generation}/src/continuous_batch_scheduler/advance_generating_phase.rs (95%) rename {paddler_agent => paddler_agent_text_generation}/src/continuous_batch_scheduler/advance_outcome.rs (100%) rename {paddler_agent => paddler_agent_text_generation}/src/continuous_batch_scheduler/assemble_batch_phase.rs (100%) rename {paddler_agent => paddler_agent_text_generation}/src/continuous_batch_scheduler/batch_pass.rs (100%) rename {paddler_agent => paddler_agent_text_generation}/src/continuous_batch_scheduler/classified_token.rs (100%) rename {paddler_agent => paddler_agent_text_generation}/src/continuous_batch_scheduler/commit_phase.rs (100%) rename {paddler_agent => paddler_agent_text_generation}/src/continuous_batch_scheduler/completion_check_outcome.rs (100%) rename {paddler_agent => paddler_agent_text_generation}/src/continuous_batch_scheduler/completion_check_phase.rs (100%) rename {paddler_agent => paddler_agent_text_generation}/src/continuous_batch_scheduler/contributions.rs (100%) rename {paddler_agent => paddler_agent_text_generation}/src/continuous_batch_scheduler/decode_failure_phase.rs (100%) rename {paddler_agent => paddler_agent_text_generation}/src/continuous_batch_scheduler/emit_token_outcome.rs (100%) rename {paddler_agent => paddler_agent_text_generation}/src/continuous_batch_scheduler/emit_token_phase.rs (100%) rename {paddler_agent => paddler_agent_text_generation}/src/continuous_batch_scheduler/generating_contribution.rs (100%) rename {paddler_agent => paddler_agent_text_generation}/src/continuous_batch_scheduler/ingesting_contribution.rs (100%) rename {paddler_agent => paddler_agent_text_generation}/src/continuous_batch_scheduler/mod.rs (77%) rename {paddler_agent => paddler_agent_text_generation}/src/continuous_batch_scheduler/sequence_ordered_insertion_index.rs (100%) rename {paddler_agent => paddler_agent_text_generation}/src/continuous_batch_scheduler_context.rs (62%) rename {paddler_agent => paddler_agent_text_generation}/src/continuous_batch_scheduler_params.rs (69%) rename {paddler_agent => paddler_agent_text_generation}/src/continuous_batch_terminal_delivery.rs (90%) rename {paddler_agent => paddler_agent_text_generation}/src/continuous_batch_terminal_outcome.rs (100%) rename {paddler_agent => paddler_agent_text_generation}/src/converts_to_mtmd_bitmap.rs (100%) rename {paddler_agent => paddler_agent_text_generation}/src/generation_request_preparer.rs (69%) rename {paddler_agent => paddler_agent_text_generation}/src/grammar_sampling.rs (100%) rename {paddler_agent => paddler_agent_text_generation}/src/image_input.rs (73%) create mode 100644 paddler_agent_text_generation/src/lib.rs rename {paddler_agent => paddler_agent_text_generation}/src/model_bos_token.rs (100%) rename {paddler_agent => paddler_agent_text_generation}/src/multimodal_ingestion_progress.rs (100%) create mode 100644 paddler_agent_text_generation/src/multimodal_projection.rs rename {paddler_agent => paddler_agent_text_generation}/src/multimodal_prompt_ingestion.rs (100%) rename {paddler_agent => paddler_agent_text_generation}/src/multimodal_prompt_support.rs (82%) create mode 100644 paddler_agent_text_generation/src/prepared_generation_request.rs rename {paddler_agent => paddler_agent_text_generation}/src/prepared_prompt.rs (100%) rename {paddler_agent => paddler_agent_text_generation}/src/prompt_modality.rs (100%) rename {paddler_agent => paddler_agent_text_generation}/src/prompt_tokenizer.rs (71%) rename {paddler_agent => paddler_agent_text_generation}/src/require_grammar_compatible_with_thinking.rs (75%) rename {paddler_agent => paddler_agent_text_generation}/src/require_prompt_fits_sequence_context.rs (80%) rename {paddler_agent => paddler_agent_text_generation}/src/resolve_grammar.rs (70%) rename {paddler_agent => paddler_agent_text_generation}/src/sampler_chain_factory.rs (61%) rename {paddler_agent => paddler_agent_text_generation}/src/sampling_outcome.rs (100%) rename paddler_agent/src/generation_request_rejection.rs => paddler_agent_text_generation/src/text_generation_error.rs (74%) create mode 100644 paddler_agent_text_generation/src/text_generation_pipeline.rs create mode 100644 paddler_agent_text_generation/src/text_generation_request.rs create mode 100644 paddler_agent_text_generation/src/text_generation_settings.rs rename {paddler_agent => paddler_agent_text_generation}/src/token_classification.rs (100%) rename {paddler_agent => paddler_agent_text_generation}/src/token_generation_support.rs (100%) rename {paddler_agent => paddler_agent_text_generation}/src/token_sampling.rs (74%) rename {paddler_agent => paddler_agent_text_generation}/src/tool_call_buffer.rs (100%) rename {paddler_agent => paddler_agent_text_generation}/src/tool_call_event.rs (100%) rename {paddler_agent => paddler_agent_text_generation}/src/tool_call_handling.rs (100%) rename {paddler_agent => paddler_agent_text_generation}/src/tool_call_pipeline.rs (100%) create mode 100644 paddler_balancer/src/agent_controller_pool_updates.rs delete mode 100644 paddler_balancer/src/cluster_token_generation_mode.rs create mode 100644 paddler_balancer/src/compatibility/agent_result_stream_event.rs create mode 100644 paddler_balancer/src/compatibility/agent_result_stream_transformer.rs create mode 100644 paddler_balancer/src/compatibility/attach_request_id.rs create mode 100644 paddler_balancer/src/compatibility/compatibility_app_data.rs create mode 100644 paddler_balancer/src/compatibility/compatibility_service.rs rename paddler_balancer/src/compatibility/{openai_service/configuration.rs => compatibility_service_configuration.rs} (69%) create mode 100644 paddler_balancer/src/compatibility/create_compatibility_cors_middleware.rs delete mode 100644 paddler_balancer/src/compatibility/openai_service/app_data.rs delete mode 100644 paddler_balancer/src/compatibility/openai_service/chat_completion_chunk_payload.rs create mode 100644 paddler_balancer/src/compatibility/openai_service/openai_compatibility_layer.rs delete mode 100644 paddler_balancer/src/compatibility/openai_service/openai_default_max_tokens.rs delete mode 100644 paddler_balancer/src/compatibility/openai_service/openai_error.rs create mode 100644 paddler_balancer/src/compatibility/openai_service/openai_error_body.rs create mode 100644 paddler_balancer/src/compatibility/openai_service/openai_failure.rs create mode 100644 paddler_balancer/src/compatibility/openai_service/openai_header.rs create mode 100644 paddler_balancer/src/compatibility/openai_service/openai_http_response.rs delete mode 100644 paddler_balancer/src/compatibility/openai_service/openai_json_response.rs delete mode 100644 paddler_balancer/src/compatibility/openai_service/openai_non_streaming_response_transformer.rs delete mode 100644 paddler_balancer/src/compatibility/openai_service/openai_non_streaming_state.rs delete mode 100644 paddler_balancer/src/compatibility/openai_service/openai_responses_function_call_output_item.rs delete mode 100644 paddler_balancer/src/compatibility/openai_service/openai_responses_function_output.rs delete mode 100644 paddler_balancer/src/compatibility/openai_service/openai_responses_input_item.rs delete mode 100644 paddler_balancer/src/compatibility/openai_service/openai_responses_reasoning.rs delete mode 100644 paddler_balancer/src/compatibility/openai_service/openai_responses_tagged_item.rs delete mode 100644 paddler_balancer/src/compatibility/openai_service/openai_responses_tool.rs create mode 100644 paddler_balancer/src/compatibility/openai_service/openai_sse_http_response.rs delete mode 100644 paddler_balancer/src/compatibility/openai_service/openai_streaming_response_transformer.rs delete mode 100644 paddler_balancer/src/compatibility/openai_service/openai_streaming_state.rs delete mode 100644 paddler_balancer/src/compatibility/openai_service/responses_non_streaming_response_transformer.rs delete mode 100644 paddler_balancer/src/compatibility/openai_service/responses_non_streaming_state.rs delete mode 100644 paddler_balancer/src/compatibility/openai_service/responses_response_header.rs delete mode 100644 paddler_balancer/src/compatibility/openai_service/responses_streaming_response_transformer.rs delete mode 100644 paddler_balancer/src/compatibility/openai_service/responses_streaming_state.rs delete mode 100644 paddler_balancer/src/compatibility/openai_service/timestamp_from.rs delete mode 100644 paddler_balancer/src/compatibility/openai_service/try_universal_error_chunk.rs create mode 100644 paddler_balancer/src/compatibility/serves_compatibility_layer.rs create mode 100644 paddler_balancer/src/compatibility/typesafe_service/http_route/get_models.rs create mode 100644 paddler_balancer/src/compatibility/typesafe_service/http_route/mod.rs create mode 100644 paddler_balancer/src/compatibility/typesafe_service/http_route/post_system_one.rs create mode 100644 paddler_balancer/src/compatibility/typesafe_service/mod.rs create mode 100644 paddler_balancer/src/compatibility/typesafe_service/system_one_http_response.rs create mode 100644 paddler_balancer/src/compatibility/typesafe_service/typesafe_api_path.rs create mode 100644 paddler_balancer/src/compatibility/typesafe_service/typesafe_compatibility_layer.rs create mode 100644 paddler_balancer/src/compatibility/typesafe_service/typesafe_error_body.rs create mode 100644 paddler_balancer/src/compatibility/typesafe_service/typesafe_header.rs create mode 100644 paddler_balancer/src/compatibility/typesafe_service/typesafe_json_config.rs create mode 100644 paddler_balancer/src/compatibility/typesafe_service/typesafe_model.rs create mode 100644 paddler_balancer/src/compatibility/typesafe_service/typesafe_models.rs create mode 100644 paddler_balancer/src/compatibility/upstream_failure.rs create mode 100644 paddler_balancer/src/inference_service/http_route/api/post_decide.rs delete mode 100644 paddler_balancer/src/inference_service/http_route/api/ws_inference_socket/spawn_token_generation_mode_watcher.rs create mode 100644 paddler_balancer/src/inference_service/inference_mode_routes.rs create mode 100644 paddler_balancer/tests/main.rs create mode 100644 paddler_balancer/tests/openai_chat_completion_reports_an_agent_whose_socket_closed_after_dispatch_as_a_bad_gateway.rs rename {paddler_bootstrap => paddler_balancer_runner}/Cargo.toml (71%) rename {paddler_bootstrap => paddler_balancer_runner}/src/balancer_defaults.rs (100%) rename {paddler_bootstrap => paddler_balancer_runner}/src/balancer_runner.rs (75%) rename paddler_bootstrap/src/balancer_bootstrap_config.rs => paddler_balancer_runner/src/balancer_runner_config.rs (82%) rename paddler_bootstrap/src/bootstrap_error.rs => paddler_balancer_runner/src/balancer_runner_error.rs (78%) rename {paddler_bootstrap => paddler_balancer_runner}/src/balancer_runner_params.rs (65%) rename {paddler_bootstrap => paddler_balancer_runner}/src/balancer_service_bundle.rs (71%) create mode 100644 paddler_balancer_runner/src/balancer_serving_mode.rs create mode 100644 paddler_balancer_runner/src/lib.rs rename {paddler_bootstrap => paddler_balancer_runner}/tests/agent_runner_reports_its_connection_to_the_balancer.rs (83%) rename {paddler_bootstrap => paddler_balancer_runner}/tests/balancer_reports_its_gauges_to_statsd.rs (90%) rename {paddler_bootstrap => paddler_balancer_runner}/tests/balancer_runner_completes_when_cancelled.rs (89%) rename {paddler_bootstrap => paddler_balancer_runner}/tests/balancer_runner_completes_when_its_parent_token_is_cancelled.rs (90%) rename {paddler_bootstrap => paddler_balancer_runner}/tests/balancer_runner_fails_to_start_when_an_address_is_taken.rs (56%) rename {paddler_bootstrap => paddler_balancer_runner}/tests/balancer_runner_fails_to_start_when_state_database_file_is_corrupt.rs (83%) rename {paddler_bootstrap => paddler_balancer_runner}/tests/balancer_runner_fails_to_start_with_a_zero_statsd_reporting_interval.rs (76%) rename {paddler_bootstrap => paddler_balancer_runner}/tests/balancer_runner_preserves_the_persisted_desired_state.rs (62%) create mode 100644 paddler_balancer_runner/tests/balancer_runner_refuses_a_stored_state_of_another_mode.rs rename {paddler_bootstrap => paddler_balancer_runner}/tests/balancer_runner_releases_its_ports_when_dropped.rs (92%) rename {paddler_bootstrap => paddler_balancer_runner}/tests/balancer_runner_serves_on_the_addresses_it_reports.rs (94%) create mode 100644 paddler_balancer_runner/tests/balancer_runner_serves_the_desired_state_it_starts_with.rs rename {paddler_bootstrap => paddler_balancer_runner}/tests/balancer_shutdown_releases_buffered_request_with_shutdown_error.rs (97%) create mode 100644 paddler_balancer_runner/tests/balancer_web_admin_panel_omits_the_services_it_does_not_run.rs create mode 100644 paddler_balancer_runner/tests/balancer_web_admin_panel_shows_the_balancer_configuration.rs create mode 100644 paddler_balancer_runner/tests/balancer_web_admin_panel_shows_the_typesafe_compatibility_address.rs rename {paddler_bootstrap => paddler_balancer_runner}/tests/ephemeral_balancer_runner_params.rs (75%) rename {paddler_bootstrap => paddler_balancer_runner}/tests/main.rs (74%) create mode 100644 paddler_balancer_runner/tests/rendered_dashboard_attributes.rs delete mode 100644 paddler_bootstrap/src/lib.rs delete mode 100644 paddler_bootstrap/tests/balancer_web_admin_panel_omits_the_services_it_does_not_run.rs delete mode 100644 paddler_bootstrap/tests/balancer_web_admin_panel_shows_the_balancer_configuration.rs create mode 100644 paddler_cli/src/cmd/balancer_decision.rs create mode 100644 paddler_cli/src/cmd/balancer_embeddings.rs create mode 100644 paddler_cli/src/cmd/balancer_shared_arguments.rs create mode 100644 paddler_cli/src/cmd/balancer_text_generation.rs create mode 100644 paddler_cli/tests/clap_usage_error_exit_code.rs rename paddler_cli/tests/{paddler_balancer_exits_with_a_failure_when_its_management_address_is_taken.rs => paddler_balancer_decision_exits_with_a_failure_when_its_management_address_is_taken.rs} (87%) create mode 100644 paddler_cli/tests/paddler_balancer_refuses_compatibility_flags_of_other_modes.rs create mode 100644 paddler_cli/tests/paddler_balancer_text_generation_exits_with_a_failure_when_its_management_address_is_taken.rs create mode 100644 paddler_cli_tests/tests/balancer_serves_typesafe_compatibility_on_the_address_it_announces.rs create mode 100644 paddler_client_javascript/src/schemas/AgentRuntimeStatus.ts create mode 100644 paddler_client_javascript/src/schemas/BalancerInferenceSettings.ts create mode 100644 paddler_client_javascript/src/schemas/BalancerTextGenerationSettings.ts create mode 100644 paddler_client_javascript/src/schemas/DecideParams.ts create mode 100644 paddler_client_javascript/src/schemas/DecisionQuestion.ts create mode 100644 paddler_client_javascript/src/schemas/DecisionSettings.ts create mode 100644 paddler_client_javascript/src/schemas/EmbeddingParameters.ts create mode 100644 paddler_client_javascript/src/schemas/InferenceMode.ts delete mode 100644 paddler_client_javascript/src/schemas/InferenceNotification.ts create mode 100644 paddler_client_javascript/src/schemas/InferenceServiceDecideResponse.ts create mode 100644 paddler_client_javascript/src/schemas/ModelRuntimeParameters.ts create mode 100644 paddler_client_javascript/src/schemas/MultimodalSettings.ts rename paddler_client_javascript/src/schemas/{InferenceParameters.ts => SamplingParameters.ts} (53%) create mode 100644 paddler_client_javascript/tests/DesiredStateServesAnotherModeError.ts rename paddler_client_javascript/tests/{assertFetchJsonReadsStoredInferenceParameters.ts => assertFetchJsonReadsStoredDesiredState.ts} (63%) delete mode 100644 paddler_client_javascript/tests/cluster/inferenceSocketClientReportsDisabledTokenGeneration.test.ts create mode 100644 paddler_client_javascript/tests/cluster/spawnedBalancerStoresTheDesiredStateOfItsInferenceMode.test.ts create mode 100644 paddler_client_javascript/tests/cluster/streamHttpNdjsonDeliversADecisionTimeoutWithoutAgents.test.ts create mode 100644 paddler_client_javascript/tests/cluster/withTextGenerationSettingsRefusesADesiredStateOfAnotherMode.test.ts delete mode 100644 paddler_client_javascript/tests/unit/schemas/InferenceNotification.test.ts create mode 100644 paddler_client_javascript/tests/unit/schemas/InferenceServiceDecideResponse.test.ts create mode 100644 paddler_client_javascript/tests/unit/schemas/ModelRuntimeParameters.test.ts rename paddler_client_javascript/tests/unit/schemas/{InferenceParameters.test.ts => SamplingParameters.test.ts} (50%) create mode 100644 paddler_client_javascript/tests/withTextGenerationSettings.ts delete mode 100644 paddler_client_python/LICENSE delete mode 100644 paddler_client_python/README.md delete mode 100644 paddler_client_python/paddler_client/__init__.py delete mode 100644 paddler_client_python/paddler_client/agent_controller_pool_snapshot.py delete mode 100644 paddler_client_python/paddler_client/agent_controller_snapshot.py delete mode 100644 paddler_client_python/paddler_client/agent_desired_model.py delete mode 100644 paddler_client_python/paddler_client/agent_issue.py delete mode 100644 paddler_client_python/paddler_client/agent_state_application_status.py delete mode 100644 paddler_client_python/paddler_client/agent_status.py delete mode 100644 paddler_client_python/paddler_client/balancer_desired_state.py delete mode 100644 paddler_client_python/paddler_client/buffered_request_manager_snapshot.py delete mode 100644 paddler_client_python/paddler_client/chat_template.py delete mode 100644 paddler_client_python/paddler_client/client_inference.py delete mode 100644 paddler_client_python/paddler_client/client_management.py delete mode 100644 paddler_client_python/paddler_client/continue_from_conversation_history_params.py delete mode 100644 paddler_client_python/paddler_client/continue_from_raw_prompt_params.py delete mode 100644 paddler_client_python/paddler_client/conversation_message.py delete mode 100644 paddler_client_python/paddler_client/conversation_message_content_part.py delete mode 100644 paddler_client_python/paddler_client/embedding.py delete mode 100644 paddler_client_python/paddler_client/embedding_input_document.py delete mode 100644 paddler_client_python/paddler_client/embedding_normalization_method.py delete mode 100644 paddler_client_python/paddler_client/error.py delete mode 100644 paddler_client_python/paddler_client/format_api_url.py delete mode 100644 paddler_client_python/paddler_client/generate_embedding_batch_params.py delete mode 100644 paddler_client_python/paddler_client/generation_finish.py delete mode 100644 paddler_client_python/paddler_client/grammar_constraint.py delete mode 100644 paddler_client_python/paddler_client/huggingface_model_reference.py delete mode 100644 paddler_client_python/paddler_client/image_url.py delete mode 100644 paddler_client_python/paddler_client/inference_message.py delete mode 100644 paddler_client_python/paddler_client/inference_parameters.py delete mode 100644 paddler_client_python/paddler_client/inference_socket_connection.py delete mode 100644 paddler_client_python/paddler_client/inference_socket_pool.py delete mode 100644 paddler_client_python/paddler_client/inference_socket_url.py delete mode 100644 paddler_client_python/paddler_client/kv_cache_dtype.py delete mode 100644 paddler_client_python/paddler_client/model_download_status.py delete mode 100644 paddler_client_python/paddler_client/model_metadata.py delete mode 100644 paddler_client_python/paddler_client/ndjson_inference_messages.py delete mode 100644 paddler_client_python/paddler_client/oversized_embedding_document_details.py delete mode 100644 paddler_client_python/paddler_client/oversized_media_details.py delete mode 100644 paddler_client_python/paddler_client/oversized_prompt_details.py delete mode 100644 paddler_client_python/paddler_client/parsed_tool_call.py delete mode 100644 paddler_client_python/paddler_client/pending_requests.py delete mode 100644 paddler_client_python/paddler_client/pooling_type.py delete mode 100644 paddler_client_python/paddler_client/raise_for_streaming_error.py delete mode 100644 paddler_client_python/paddler_client/raw_tool_call_tokens.py delete mode 100644 paddler_client_python/paddler_client/response_stream.py delete mode 100644 paddler_client_python/paddler_client/stream_ndjson.py delete mode 100644 paddler_client_python/paddler_client/stream_sse.py delete mode 100644 paddler_client_python/paddler_client/tool.py delete mode 100644 paddler_client_python/paddler_client/tool_call_arguments.py delete mode 100644 paddler_client_python/paddler_client/unparsed_message_request_id.py delete mode 100644 paddler_client_python/paddler_client/url_model_reference.py delete mode 100644 paddler_client_python/paddler_client/validated_parameters_schema.py delete mode 100644 paddler_client_python/poetry.lock delete mode 100644 paddler_client_python/pyproject.toml delete mode 100644 paddler_client_python/tests/cluster/conftest.py delete mode 100644 paddler_client_python/tests/cluster/test_inference_over_http_times_out_without_agents.py delete mode 100644 paddler_client_python/tests/cluster/test_inference_reports_its_health.py delete mode 100644 paddler_client_python/tests/cluster/test_inference_reports_unsuccessful_responses.py delete mode 100644 paddler_client_python/tests/cluster/test_inference_socket_answers_consecutive_requests_without_agents.py delete mode 100644 paddler_client_python/tests/cluster/test_inference_socket_connection_rejects_requests_after_it_closes.py delete mode 100644 paddler_client_python/tests/cluster/test_inference_socket_reconnects_after_the_balancer_closes_it.py delete mode 100644 paddler_client_python/tests/cluster/test_inference_socket_reports_disabled_token_generation.py delete mode 100644 paddler_client_python/tests/cluster/test_management_agents_stream_ends_when_the_balancer_shuts_down.py delete mode 100644 paddler_client_python/tests/cluster/test_management_exposes_prometheus_gauges.py delete mode 100644 paddler_client_python/tests/cluster/test_management_lists_registered_agents.py delete mode 100644 paddler_client_python/tests/cluster/test_management_reports_its_health.py delete mode 100644 paddler_client_python/tests/cluster/test_management_reports_no_buffered_requests_while_idle.py delete mode 100644 paddler_client_python/tests/cluster/test_management_reports_no_chat_template_override_for_an_agent_without_one.py delete mode 100644 paddler_client_python/tests/cluster/test_management_reports_no_model_metadata_for_an_agent_without_a_model.py delete mode 100644 paddler_client_python/tests/cluster/test_management_reports_the_chat_template_override_an_agent_applies.py delete mode 100644 paddler_client_python/tests/cluster/test_management_reports_unsuccessful_responses.py delete mode 100644 paddler_client_python/tests/cluster/test_management_serves_the_desired_state_it_stores.py delete mode 100644 paddler_client_python/tests/cluster/test_management_streams_agent_snapshots.py delete mode 100644 paddler_client_python/tests/cluster/test_management_streams_buffered_request_snapshots.py delete mode 100644 paddler_client_python/tests/llm/__init__.py delete mode 100644 paddler_client_python/tests/llm/conftest.py delete mode 100644 paddler_client_python/tests/llm/test_conversation_history_streams_tokens_over_http.py delete mode 100644 paddler_client_python/tests/llm/test_conversation_history_streams_tokens_over_the_inference_socket.py delete mode 100644 paddler_client_python/tests/llm/test_conversation_with_an_invalid_tool_schema_is_rejected.py delete mode 100644 paddler_client_python/tests/llm/test_embedding_batch_reports_documents_exceeding_the_batch_size.py delete mode 100644 paddler_client_python/tests/llm/test_embedding_batch_returns_one_embedding_per_document.py delete mode 100644 paddler_client_python/tests/llm/test_management_reports_the_model_metadata_of_a_loaded_model.py delete mode 100644 paddler_client_python/tests/llm/test_raw_prompt_streams_tokens_over_the_inference_socket.py delete mode 100644 paddler_client_python/tests/llm/test_tool_call_over_the_inference_socket_streams_until_done.py delete mode 100644 paddler_client_python/tests/unit/__init__.py delete mode 100644 paddler_client_python/tests/unit/test_agent_controller_pool_snapshot.py delete mode 100644 paddler_client_python/tests/unit/test_agent_controller_snapshot.py delete mode 100644 paddler_client_python/tests/unit/test_agent_desired_model.py delete mode 100644 paddler_client_python/tests/unit/test_agent_issue.py delete mode 100644 paddler_client_python/tests/unit/test_agent_state_application_status.py delete mode 100644 paddler_client_python/tests/unit/test_balancer_desired_state.py delete mode 100644 paddler_client_python/tests/unit/test_buffered_request_manager_snapshot.py delete mode 100644 paddler_client_python/tests/unit/test_chat_template.py delete mode 100644 paddler_client_python/tests/unit/test_continue_from_conversation_history_params.py delete mode 100644 paddler_client_python/tests/unit/test_continue_from_conversation_history_params_rejects_tool_call_parsing_without_tools.py delete mode 100644 paddler_client_python/tests/unit/test_continue_from_raw_prompt_params.py delete mode 100644 paddler_client_python/tests/unit/test_conversation_message.py delete mode 100644 paddler_client_python/tests/unit/test_embedding.py delete mode 100644 paddler_client_python/tests/unit/test_embedding_input_document.py delete mode 100644 paddler_client_python/tests/unit/test_embedding_normalization_method.py delete mode 100644 paddler_client_python/tests/unit/test_format_api_url.py delete mode 100644 paddler_client_python/tests/unit/test_generate_embedding_batch_params.py delete mode 100644 paddler_client_python/tests/unit/test_grammar_constraint.py delete mode 100644 paddler_client_python/tests/unit/test_inference_message.py delete mode 100644 paddler_client_python/tests/unit/test_inference_parameters.py delete mode 100644 paddler_client_python/tests/unit/test_inference_socket_pool_rejects_an_empty_pool.py delete mode 100644 paddler_client_python/tests/unit/test_inference_socket_url.py delete mode 100644 paddler_client_python/tests/unit/test_kv_cache_dtype.py delete mode 100644 paddler_client_python/tests/unit/test_model_download_status.py delete mode 100644 paddler_client_python/tests/unit/test_model_metadata.py delete mode 100644 paddler_client_python/tests/unit/test_ndjson_inference_messages_skip_blank_lines.py delete mode 100644 paddler_client_python/tests/unit/test_parsed_tool_call.py delete mode 100644 paddler_client_python/tests/unit/test_pending_requests_delivers_a_message_to_its_request.py delete mode 100644 paddler_client_python/tests/unit/test_pending_requests_drops_every_request_when_drained.py delete mode 100644 paddler_client_python/tests/unit/test_pending_requests_fails_a_request_whose_error_cannot_be_parsed.py delete mode 100644 paddler_client_python/tests/unit/test_pending_requests_fails_a_request_whose_response_cannot_be_parsed.py delete mode 100644 paddler_client_python/tests/unit/test_pending_requests_ignores_a_message_for_an_unknown_request.py delete mode 100644 paddler_client_python/tests/unit/test_pending_requests_ignores_malformed_messages_it_cannot_attribute.py delete mode 100644 paddler_client_python/tests/unit/test_pending_requests_keeps_the_first_of_two_registrations_of_a_request.py delete mode 100644 paddler_client_python/tests/unit/test_pooling_type.py delete mode 100644 paddler_client_python/tests/unit/test_tool.py delete mode 100644 paddler_client_python/tests/unit/test_tool_call_arguments.py delete mode 100644 paddler_client_python/tests/unit/validation_error_cause.py create mode 100644 paddler_gui/src/inference_mode_choice.rs create mode 100644 paddler_gui/src/runner_failure.rs create mode 100644 paddler_inference_parameters/src/embedding_parameters.rs create mode 100644 paddler_inference_parameters/src/model_runtime_parameters.rs delete mode 100644 paddler_inference_parameters/src/raw_inference_parameters.rs create mode 100644 paddler_inference_parameters/src/raw_model_runtime_parameters.rs create mode 100644 paddler_inference_parameters/src/raw_sampling_parameters.rs rename paddler_inference_parameters/src/{inference_parameters.rs => sampling_parameters.rs} (61%) rename {paddler_client_python => paddler_kev_converter_python}/.gitignore (100%) rename {paddler_client_python/tests => paddler_kev_converter_python/paddler_kev_converter}/__init__.py (100%) create mode 100644 paddler_kev_converter_python/paddler_kev_converter/command_line.py create mode 100644 paddler_kev_converter_python/paddler_kev_converter/convertible_kev_checkpoint.py create mode 100644 paddler_kev_converter_python/paddler_kev_converter/error.py create mode 100644 paddler_kev_converter_python/paddler_kev_converter/malformed_pointer_head.py create mode 100644 paddler_kev_converter_python/paddler_kev_converter/parity_reference.py create mode 100644 paddler_kev_converter_python/paddler_kev_converter/pointer_head.py create mode 100644 paddler_kev_converter_python/paddler_kev_converter/synthetic_pointer_head.py create mode 100644 paddler_kev_converter_python/paddler_kev_converter/tokenizer_reference.py create mode 100644 paddler_kev_converter_python/paddler_kev_converter/typesafe_reference.py create mode 100644 paddler_kev_converter_python/poetry.lock create mode 100644 paddler_kev_converter_python/pyproject.toml rename paddler_client_python/paddler_client/py.typed => paddler_kev_converter_python/stubs/kev/__init__.pyi (100%) create mode 100644 paddler_kev_converter_python/stubs/kev/api.pyi create mode 100644 paddler_kev_converter_python/stubs/kev/checkpoint.pyi create mode 100644 paddler_kev_converter_python/stubs/kev/model.pyi rename {paddler_client_python/tests/cluster => paddler_kev_converter_python/tests}/__init__.py (100%) create mode 100644 paddler_kev_converter_python/tests/conftest.py create mode 100644 paddler_kev_converter_python/tests/test_a_checkpoint_on_an_attention_only_base_is_refused.py create mode 100644 paddler_kev_converter_python/tests/test_a_checkpoint_trained_on_a_bf16_backbone_is_refused.py create mode 100644 paddler_kev_converter_python/tests/test_a_checkpoint_training_token_embeddings_is_refused.py create mode 100644 paddler_kev_converter_python/tests/test_a_full_weight_checkpoint_is_refused.py create mode 100644 paddler_kev_converter_python/tests/test_an_option_isolating_checkpoint_is_refused.py create mode 100644 paddler_kev_converter_python/tests/test_converting_copies_the_base_tokenizer.py create mode 100644 paddler_kev_converter_python/tests/test_converting_writes_the_merged_backbone_as_a_causal_language_model.py create mode 100644 paddler_kev_converter_python/tests/test_converting_writes_the_pointer_head_of_the_checkpoint.py create mode 100644 paddler_kev_converter_python/tests/test_malformed_pointer_heads_carry_their_defect.py create mode 100644 paddler_kev_converter_python/tests/test_synthetic_pointer_heads_are_reproducible.py create mode 100644 paddler_kev_converter_python/tests/test_the_parity_reference_pairs_rendered_records_with_kev_probabilities.py create mode 100644 paddler_kev_converter_python/tests/test_the_tokenizer_reference_pairs_texts_with_base_token_ids.py create mode 100644 paddler_kev_converter_python/tests/test_the_typesafe_reference_renders_and_answers_like_kev.py create mode 100644 paddler_llama_cpp_converter_python/.gitignore create mode 100644 paddler_llama_cpp_converter_python/poetry.lock create mode 100644 paddler_llama_cpp_converter_python/pyproject.toml create mode 100644 paddler_messaging/src/agent_inference_settings.rs create mode 100644 paddler_messaging/src/agent_issue_params/model_architecture_unsupported_for_decisions_params.rs create mode 100644 paddler_messaging/src/agent_issue_params/pointer_head_incompatibility.rs create mode 100644 paddler_messaging/src/agent_issue_params/pointer_head_incompatible_with_model_params.rs create mode 100644 paddler_messaging/src/agent_issue_params/slots_insufficient_for_decisions_params.rs create mode 100644 paddler_messaging/src/agent_runtime_status.rs create mode 100644 paddler_messaging/src/agent_text_generation_settings.rs create mode 100644 paddler_messaging/src/balancer_inference_settings.rs create mode 100644 paddler_messaging/src/balancer_text_generation_settings.rs create mode 100644 paddler_messaging/src/chat_template_source.rs create mode 100644 paddler_messaging/src/decision_answer.rs create mode 100644 paddler_messaging/src/decision_question.rs create mode 100644 paddler_messaging/src/decision_result.rs create mode 100644 paddler_messaging/src/decision_settings.rs rename paddler_messaging/src/{inference_client/notification.rs => decision_summary.rs} (59%) create mode 100644 paddler_messaging/src/inference_mode.rs create mode 100644 paddler_messaging/src/multimodal_settings.rs create mode 100644 paddler_messaging/src/oversized_decision_details.rs create mode 100644 paddler_messaging/src/request_params/decide_params/mod.rs create mode 100644 paddler_messaging/src/request_params/decide_params/raw_decide_params.rs rename paddler_openai_client_python_test/tests/{test_chat_completion_returns_message_content_and_usage.py => test_chat_completions_return_message_content_and_usage.py} (88%) rename paddler_openai_client_python_test/tests/{test_chat_completion_stream_accumulates_content_and_reports_usage.py => test_chat_completions_stream_accumulates_content_and_reports_usage.py} (92%) rename paddler_openai_client_python_test/tests/{test_response_returns_output_text_and_usage.py => test_responses_return_output_text_and_usage.py} (89%) rename paddler_openai_client_python_test/tests/{test_response_stream_reaches_completed_and_accumulates_output_text.py => test_responses_stream_reaches_completed_and_accumulates_output_text.py} (88%) create mode 100644 paddler_openai_translation/Cargo.toml rename {paddler_balancer/src/compatibility/openai_service => paddler_openai_translation/src}/arguments_to_tool_call_string.rs (100%) rename {paddler_balancer/src/compatibility/openai_service => paddler_openai_translation/src}/assistant_role.rs (100%) rename {paddler_balancer/src/compatibility/openai_service => paddler_openai_translation/src}/chat_completion.rs (77%) rename {paddler_balancer/src/compatibility/openai_service => paddler_openai_translation/src}/chat_completion_chunk.rs (54%) rename {paddler_balancer/src/compatibility/openai_service => paddler_openai_translation/src}/chat_completion_chunk_choice.rs (85%) create mode 100644 paddler_openai_translation/src/chat_completion_chunk_payload.rs create mode 100644 paddler_openai_translation/src/chat_completion_delivery.rs rename {paddler_balancer/src/compatibility/openai_service => paddler_openai_translation/src}/chat_completion_finish_reason.rs (100%) create mode 100644 paddler_openai_translation/src/chat_completion_header.rs rename paddler_balancer/src/compatibility/openai_service/openai_message.rs => paddler_openai_translation/src/chat_completion_message.rs (86%) rename paddler_balancer/src/compatibility/openai_service/openai_completion_request_params.rs => paddler_openai_translation/src/chat_completion_request.rs (61%) create mode 100644 paddler_openai_translation/src/chat_completion_stream.rs rename paddler_balancer/src/compatibility/openai_service/stream_options.rs => paddler_openai_translation/src/chat_completion_stream_options.rs (77%) create mode 100644 paddler_openai_translation/src/chat_completion_stream_params.rs rename paddler_balancer/src/compatibility/openai_service/openai_chat_completion_tool.rs => paddler_openai_translation/src/chat_completion_tool.rs (77%) rename {paddler_balancer/src/compatibility/openai_service => paddler_openai_translation/src}/chat_completion_tool_call.rs (91%) rename paddler_balancer/src/compatibility/openai_service/openai_usage.rs => paddler_openai_translation/src/chat_completion_usage.rs (93%) create mode 100644 paddler_openai_translation/src/completes_generation.rs rename {paddler_balancer/src/compatibility/openai_service => paddler_openai_translation/src}/content_part_event.rs (73%) create mode 100644 paddler_openai_translation/src/default_max_tokens.rs rename {paddler_balancer/src/compatibility/openai_service => paddler_openai_translation/src}/function_call_arguments_delta_event.rs (100%) rename {paddler_balancer/src/compatibility/openai_service => paddler_openai_translation/src}/function_call_arguments_done_event.rs (100%) rename paddler_balancer/src/compatibility/openai_service/openai_function_definition.rs => paddler_openai_translation/src/function_definition.rs (86%) create mode 100644 paddler_openai_translation/src/generated_output.rs create mode 100644 paddler_openai_translation/src/generated_output_part.rs create mode 100644 paddler_openai_translation/src/generation_event.rs create mode 100644 paddler_openai_translation/src/generation_failure.rs create mode 100644 paddler_openai_translation/src/generation_failure_cause.rs create mode 100644 paddler_openai_translation/src/lib.rs rename {paddler_balancer/src/compatibility/openai_service => paddler_openai_translation/src}/open_item.rs (100%) create mode 100644 paddler_openai_translation/src/openai_translation_error.rs rename {paddler_balancer/src/compatibility/openai_service => paddler_openai_translation/src}/output_item_event.rs (68%) rename paddler_balancer/src/compatibility/openai_service/openai_reasoning_effort.rs => paddler_openai_translation/src/reasoning_effort.rs (75%) rename {paddler_balancer/src/compatibility/openai_service => paddler_openai_translation/src}/response_snapshot_event.rs (66%) rename {paddler_balancer/src/compatibility/openai_service => paddler_openai_translation/src}/responses_content_part.rs (100%) create mode 100644 paddler_openai_translation/src/responses_delivery.rs rename paddler_balancer/src/compatibility/openai_service/openai_responses_function_call_item.rs => paddler_openai_translation/src/responses_function_call_item.rs (73%) create mode 100644 paddler_openai_translation/src/responses_function_call_output_item.rs create mode 100644 paddler_openai_translation/src/responses_function_output.rs rename paddler_balancer/src/compatibility/openai_service/openai_responses_function_output_part.rs => paddler_openai_translation/src/responses_function_output_part.rs (75%) rename paddler_balancer/src/compatibility/openai_service/openai_responses_input.rs => paddler_openai_translation/src/responses_input.rs (65%) rename paddler_balancer/src/compatibility/openai_service/openai_responses_input_content_part.rs => paddler_openai_translation/src/responses_input_content_part.rs (85%) create mode 100644 paddler_openai_translation/src/responses_input_item.rs rename {paddler_balancer/src/compatibility/openai_service => paddler_openai_translation/src}/responses_item_status.rs (100%) rename paddler_balancer/src/compatibility/openai_service/openai_responses_message_content.rs => paddler_openai_translation/src/responses_message_content.rs (62%) rename paddler_balancer/src/compatibility/openai_service/openai_responses_message_item.rs => paddler_openai_translation/src/responses_message_item.rs (69%) rename {paddler_balancer/src/compatibility/openai_service => paddler_openai_translation/src}/responses_output_item.rs (85%) rename {paddler_balancer/src/compatibility/openai_service => paddler_openai_translation/src}/responses_output_item_kind.rs (100%) create mode 100644 paddler_openai_translation/src/responses_reasoning.rs rename {paddler_balancer/src/compatibility/openai_service => paddler_openai_translation/src}/responses_reasoning_part.rs (100%) rename paddler_balancer/src/compatibility/openai_service/openai_responses_request_params.rs => paddler_openai_translation/src/responses_request.rs (58%) rename {paddler_balancer/src/compatibility/openai_service => paddler_openai_translation/src}/responses_response.rs (89%) create mode 100644 paddler_openai_translation/src/responses_response_header.rs rename {paddler_balancer/src/compatibility/openai_service => paddler_openai_translation/src}/responses_response_progress.rs (66%) rename {paddler_balancer/src/compatibility/openai_service => paddler_openai_translation/src}/responses_response_status.rs (100%) create mode 100644 paddler_openai_translation/src/responses_stream.rs rename {paddler_balancer/src/compatibility/openai_service => paddler_openai_translation/src}/responses_stream_event.rs (90%) create mode 100644 paddler_openai_translation/src/responses_tagged_item.rs rename paddler_balancer/src/compatibility/openai_service/openai_responses_text_format.rs => paddler_openai_translation/src/responses_text_format.rs (84%) rename paddler_balancer/src/compatibility/openai_service/openai_responses_text_param.rs => paddler_openai_translation/src/responses_text_param.rs (53%) create mode 100644 paddler_openai_translation/src/responses_tool.rs rename {paddler_balancer/src/compatibility/openai_service => paddler_openai_translation/src}/responses_usage.rs (100%) rename {paddler_balancer/src/compatibility/openai_service => paddler_openai_translation/src}/text_delta_event.rs (100%) rename {paddler_balancer/src/compatibility/openai_service => paddler_openai_translation/src}/text_done_event.rs (100%) create mode 100644 paddler_openai_translation/src/timestamp_from.rs rename paddler_balancer/src/compatibility/openai_service/openai_tool_parameters_schema.rs => paddler_openai_translation/src/tool_parameters_schema.rs (89%) create mode 100644 paddler_openai_translation/src/translated_chat_completion_request.rs rename paddler_balancer/src/compatibility/openai_service/responses_prepared_request.rs => paddler_openai_translation/src/translated_responses_request.rs (56%) create mode 100644 paddler_openai_translation/tests/a_chat_completion_request_with_an_unsatisfiable_tool_is_refused.rs create mode 100644 paddler_openai_translation/tests/a_request_translated_before_the_unix_epoch_is_refused.rs create mode 100644 paddler_openai_translation/tests/a_responses_request_to_a_cluster_of_another_inference_mode_is_refused.rs create mode 100644 paddler_openai_translation/tests/chat_completion_requests_translate_into_their_delivery.rs create mode 100644 paddler_openai_translation/tests/main.rs create mode 100644 paddler_openai_translation/tests/responses_requests_report_the_sampling_parameters_in_effect.rs create mode 100644 paddler_service_thread/Cargo.toml create mode 100644 paddler_service_thread/src/lib.rs rename {paddler_bootstrap => paddler_service_thread}/src/run_service_manager.rs (100%) rename {paddler_bootstrap => paddler_service_thread}/src/service_thread.rs (83%) create mode 100644 paddler_service_thread/src/service_thread_error.rs create mode 100644 paddler_state_database/src/ensure_requested_state_serves_cluster_mode.rs create mode 100644 paddler_state_database/src/ensure_stored_state_serves_cluster_mode.rs create mode 100644 paddler_state_database/src/schema_version_header.rs create mode 100644 paddler_test_cluster_harness/src/cluster_desired_state.rs create mode 100644 paddler_test_cluster_harness/src/collect_decision_results.rs create mode 100644 paddler_test_cluster_harness/src/collected_decision_results.rs create mode 100644 paddler_test_cluster_harness/src/typesafe_api_client.rs create mode 100644 paddler_test_cluster_harness/src/typesafe_api_response.rs delete mode 100644 paddler_test_cluster_python/.gitignore delete mode 100644 paddler_test_cluster_python/paddler_test_cluster/__init__.py delete mode 100644 paddler_test_cluster_python/paddler_test_cluster/agent_spec.py delete mode 100644 paddler_test_cluster_python/paddler_test_cluster/all_gpu_layers.py delete mode 100644 paddler_test_cluster_python/paddler_test_cluster/balancer_addresses.py delete mode 100644 paddler_test_cluster_python/paddler_test_cluster/error.py delete mode 100644 paddler_test_cluster_python/paddler_test_cluster/nomic_embed_text_v1_5_desired_state.py delete mode 100644 paddler_test_cluster_python/paddler_test_cluster/paddler_binary_path.py delete mode 100644 paddler_test_cluster_python/paddler_test_cluster/paddler_cluster.py delete mode 100644 paddler_test_cluster_python/paddler_test_cluster/py.typed delete mode 100644 paddler_test_cluster_python/paddler_test_cluster/qwen3_0_6b_desired_state.py delete mode 100644 paddler_test_cluster_python/paddler_test_cluster/spawned_agent.py delete mode 100644 paddler_test_cluster_python/paddler_test_cluster/spawned_balancer.py delete mode 100644 paddler_test_cluster_python/paddler_test_cluster/spawned_process.py delete mode 100644 paddler_test_cluster_python/paddler_test_cluster/wait_for_agent_ready.py delete mode 100644 paddler_test_cluster_python/tests/__init__.py delete mode 100644 paddler_test_cluster_python/tests/cluster/__init__.py delete mode 100644 paddler_test_cluster_python/tests/cluster/test_agent_reporting_issues_fails_the_cluster.py delete mode 100644 paddler_test_cluster_python/tests/cluster/test_agents_stream_closing_before_readiness_is_reported.py delete mode 100644 paddler_test_cluster_python/tests/cluster/test_balancer_serves_on_the_addresses_it_announces.py delete mode 100644 paddler_test_cluster_python/tests/cluster/test_balancer_that_exits_before_announcing_is_reported.py delete mode 100644 paddler_test_cluster_python/tests/cluster/test_cluster_without_a_model_registers_its_agents.py delete mode 100644 paddler_test_cluster_python/tests/cluster/test_process_that_exited_with_a_failure_is_reported.py delete mode 100644 paddler_test_cluster_python/tests/cluster/test_process_that_ignores_termination_is_reported.py delete mode 100644 paddler_test_cluster_python/tests/llm/__init__.py delete mode 100644 paddler_test_cluster_python/tests/llm/test_cluster_with_a_model_waits_until_every_slot_loads_it.py delete mode 100644 paddler_test_cluster_python/tests/unit/__init__.py delete mode 100644 paddler_test_cluster_python/tests/unit/test_compat_openai_url_requires_the_service.py create mode 100644 paddler_tests/src/bin/run_with_test_cluster.rs create mode 100644 paddler_tests/src/decision_cluster_params.rs create mode 100644 paddler_tests/src/decision_cluster_without_agents_params.rs create mode 100644 paddler_tests/src/desired_state_with_chat_template_override.rs create mode 100644 paddler_tests/src/desired_state_with_multimodal_projection.rs create mode 100644 paddler_tests/src/kev_0_8b_desired_state.rs create mode 100644 paddler_tests/src/kev_parity_record.rs delete mode 100644 paddler_tests/src/load_qwen3_after_request_is_buffered.rs create mode 100644 paddler_tests/src/many_question_decision.rs delete mode 100644 paddler_tests/src/qwen3_desired_state_with_embeddings.rs create mode 100644 paddler_tests/src/sample_decision.rs create mode 100644 paddler_tests/src/serving_agent_status.rs delete mode 100644 paddler_tests/src/start_cluster_without_model.rs create mode 100644 paddler_tests/src/start_decision_cluster.rs create mode 100644 paddler_tests/src/test_cluster_preset.rs rename paddler_tests/tests/{balancer_rejects_embedding_batch_with_501_when_embeddings_are_disabled.rs => a_text_generation_cluster_serves_no_embedding_route.rs} (65%) create mode 100644 paddler_tests/tests/agent_rejects_a_multimodal_conversation_containing_a_nul_byte.rs delete mode 100644 paddler_tests/tests/agent_rejects_buffered_conversation_after_switching_to_embeddings.rs delete mode 100644 paddler_tests/tests/agent_rejects_buffered_embedding_batch_after_disabling_embeddings.rs delete mode 100644 paddler_tests/tests/agent_rejects_buffered_raw_prompt_after_switching_to_embeddings.rs create mode 100644 paddler_tests/tests/agent_reports_a_model_architecture_that_cannot_decide.rs create mode 100644 paddler_tests/tests/agent_reports_a_pointer_head_it_cannot_load.rs create mode 100644 paddler_tests/tests/agent_reports_a_pointer_head_of_another_hidden_size.rs create mode 100644 paddler_tests/tests/agent_reports_pointer_head_delimiters_that_are_not_control_tokens.rs create mode 100644 paddler_tests/tests/agent_reports_slot_cannot_start_for_a_batch_smaller_than_its_slots.rs create mode 100644 paddler_tests/tests/agent_reports_too_few_slots_for_decisions.rs create mode 100644 paddler_tests/tests/agent_serves_with_a_batch_too_small_to_warm_up_every_slot.rs create mode 100644 paddler_tests/tests/agent_stops_an_embedding_batch_when_the_client_disconnects.rs rename paddler_tests/tests/{balancer_rejects_http_token_generation_with_501_while_the_cluster_serves_embeddings.rs => an_embeddings_cluster_serves_no_token_generation_routes.rs} (66%) delete mode 100644 paddler_tests/tests/balancer_inference_socket_recovers_from_embeddings_mode.rs delete mode 100644 paddler_tests/tests/continuous_batch_rejects_embedding_during_active_generation.rs create mode 100644 paddler_tests/tests/decision_agent_shuts_down_cleanly_with_decisions_in_flight.rs create mode 100644 paddler_tests/tests/decision_answers_equal_separate_single_question_decisions.rs create mode 100644 paddler_tests/tests/decision_answers_every_question_in_order.rs create mode 100644 paddler_tests/tests/decision_client_disconnecting_mid_decision_releases_its_sequences.rs create mode 100644 paddler_tests/tests/decision_exceeding_the_context_is_rejected.rs create mode 100644 paddler_tests/tests/decision_in_flight_is_answered_in_full_when_the_desired_state_changes.rs create mode 100644 paddler_tests/tests/decision_input_with_a_nul_byte_is_rejected.rs create mode 100644 paddler_tests/tests/decision_rows_longer_than_the_batch_are_answered.rs create mode 100644 paddler_tests/tests/decision_text_matches_the_hugging_face_tokenizer.rs create mode 100644 paddler_tests/tests/decision_without_questions_is_rejected_with_bad_request.rs create mode 100644 paddler_tests/tests/decisions_are_identical_alone_and_concurrently.rs create mode 100644 paddler_tests/tests/half_closed_decision_client_releases_its_sequences.rs delete mode 100644 paddler_tests/tests/inference_socket_notifies_clients_only_when_token_generation_mode_changes.rs create mode 100644 paddler_tests/tests/kev_decisions_match_the_fp32_reference.rs rename paddler_tests/tests/{qwen3_openai_non_streaming_emits_tool_calls_for_function_tool.rs => openai_chat_completion_non_streaming_emits_tool_calls_for_function_tool.rs} (97%) rename paddler_tests/tests/{half_closed_openai_non_streaming_client_releases_the_buffered_request.rs => openai_chat_completion_non_streaming_half_closed_client_releases_the_buffered_request.rs} (87%) rename paddler_tests/tests/{agent_openai_chat_completions_non_streaming_returns_text.rs => openai_chat_completion_non_streaming_returns_text.rs} (93%) rename paddler_tests/tests/{qwen3_openai_non_streaming_returns_usage.rs => openai_chat_completion_non_streaming_returns_usage.rs} (96%) rename paddler_tests/tests/{qwen3_openai_non_streaming_usage_with_tool_calls.rs => openai_chat_completion_non_streaming_usage_with_tool_calls.rs} (97%) create mode 100644 paddler_tests/tests/openai_chat_completion_reports_balancer_failures_with_their_http_status.rs rename paddler_tests/tests/{qwen3_openai_streaming_emits_tool_calls_for_function_tool.rs => openai_chat_completion_streaming_emits_tool_calls_for_function_tool.rs} (97%) rename paddler_tests/tests/{qwen3_openai_streaming_emits_usage_when_requested.rs => openai_chat_completion_streaming_emits_usage_when_requested.rs} (96%) rename paddler_tests/tests/{half_closed_openai_streaming_client_releases_the_buffered_request.rs => openai_chat_completion_streaming_half_closed_client_releases_the_buffered_request.rs} (87%) rename paddler_tests/tests/{qwen3_openai_streaming_omits_usage_when_not_requested.rs => openai_chat_completion_streaming_omits_usage_when_not_requested.rs} (93%) rename paddler_tests/tests/{agent_openai_chat_completions_streaming_returns_chunks.rs => openai_chat_completion_streaming_returns_chunks.rs} (93%) rename paddler_tests/tests/{qwen3_openai_streaming_usage_breakdown_with_thinking.rs => openai_chat_completion_streaming_usage_breakdown_with_thinking.rs} (95%) rename paddler_tests/tests/{qwen3_openai_chat_completion_with_reasoning_effort_none_generates_no_reasoning_tokens.rs => openai_chat_completion_with_reasoning_effort_none_generates_no_reasoning_tokens.rs} (91%) rename paddler_tests/tests/{balancer_openai_compat_health_returns_ok.rs => openai_health_returns_ok.rs} (82%) delete mode 100644 paddler_tests/tests/openai_responses_are_rejected_while_the_cluster_serves_embeddings.rs rename paddler_tests/tests/{half_closed_openai_responses_client_releases_the_buffered_request.rs => openai_responses_half_closed_client_releases_the_buffered_request.rs} (90%) rename paddler_tests/tests/{qwen3_responses_non_streaming_returns_text_and_usage.rs => openai_responses_non_streaming_returns_text_and_usage.rs} (96%) create mode 100644 paddler_tests/tests/openai_service_lets_browsers_send_and_read_openai_headers.rs create mode 100644 paddler_tests/tests/run_with_test_cluster_exposes_only_the_services_its_cluster_serves.rs create mode 100644 paddler_tests/tests/typesafe_health_returns_ok.rs create mode 100644 paddler_tests/tests/typesafe_models_lists_the_model_the_cluster_serves.rs create mode 100644 paddler_tests/tests/typesafe_service_lets_browsers_send_and_read_typesafe_headers.rs create mode 100644 paddler_tests/tests/typesafe_system_one_answers_questions_in_their_order.rs create mode 100644 paddler_tests/tests/typesafe_system_one_answers_with_the_decision_model.rs create mode 100644 paddler_tests/tests/typesafe_system_one_rejects_malformed_requests_as_unprocessable.rs create mode 100644 paddler_tests/tests/typesafe_system_one_reports_balancer_failures_with_their_http_status.rs create mode 100644 paddler_typesafe_client_python_test/.gitignore create mode 100644 paddler_typesafe_client_python_test/README.md rename {paddler_test_cluster_python => paddler_typesafe_client_python_test}/poetry.lock (57%) rename {paddler_test_cluster_python => paddler_typesafe_client_python_test}/pyproject.toml (51%) create mode 100644 paddler_typesafe_client_python_test/tests/conftest.py create mode 100644 paddler_typesafe_client_python_test/tests/test_models_list_returns_the_served_model.py create mode 100644 paddler_typesafe_client_python_test/tests/test_system_one_answers_every_question_type.py create mode 100644 paddler_typesafe_client_python_test/tests/test_system_one_rejects_a_choice_without_criteria.py create mode 100644 paddler_typesafe_translation/Cargo.toml create mode 100644 paddler_typesafe_translation/src/choice_confidence.rs create mode 100644 paddler_typesafe_translation/src/lib.rs create mode 100644 paddler_typesafe_translation/src/normalized_probabilities.rs create mode 100644 paddler_typesafe_translation/src/python_float_repr.rs create mode 100644 paddler_typesafe_translation/src/render_json_content.rs create mode 100644 paddler_typesafe_translation/src/round_probability.rs create mode 100644 paddler_typesafe_translation/src/score_confidence.rs create mode 100644 paddler_typesafe_translation/src/system_one_answer.rs create mode 100644 paddler_typesafe_translation/src/system_one_answer_layout.rs create mode 100644 paddler_typesafe_translation/src/system_one_question.rs create mode 100644 paddler_typesafe_translation/src/system_one_request.rs create mode 100644 paddler_typesafe_translation/src/system_one_response.rs create mode 100644 paddler_typesafe_translation/src/system_one_usage.rs create mode 100644 paddler_typesafe_translation/src/translated_system_one_question.rs create mode 100644 paddler_typesafe_translation/src/translated_system_one_request.rs create mode 100644 paddler_typesafe_translation/src/typesafe_translation_error.rs create mode 100644 paddler_typesafe_translation/tests/a_malformed_question_is_refused_with_its_id.rs create mode 100644 paddler_typesafe_translation/tests/a_noul_question_with_null_criteria_describes_neither_outcome.rs create mode 100644 paddler_typesafe_translation/tests/a_system_one_request_without_questions_is_refused.rs create mode 100644 paddler_typesafe_translation/tests/answers_that_do_not_fit_the_questions_are_refused.rs create mode 100644 paddler_typesafe_translation/tests/main.rs create mode 100644 paddler_typesafe_translation/tests/questions_with_too_many_or_no_criteria_are_refused.rs create mode 100644 paddler_typesafe_translation/tests/translation_matches_the_kev_typesafe_reference.rs create mode 100644 resources/ts/components/BalancerDesiredStateContextProvider.tsx create mode 100644 resources/ts/components/EmbeddingParametersFields.tsx delete mode 100644 resources/ts/components/InferenceParameterCacheDtype.tsx delete mode 100644 resources/ts/components/InferenceParameterCheckbox.tsx delete mode 100644 resources/ts/components/InferenceParameterPoolingType.tsx delete mode 100644 resources/ts/components/InferenceParametersContextProvider.tsx create mode 100644 resources/ts/components/ModelRuntimeParametersFields.tsx rename resources/ts/components/{InferenceParameterInput.tsx => ParameterNumberInput.tsx} (56%) create mode 100644 resources/ts/components/ParameterSelect.tsx create mode 100644 resources/ts/components/TextGenerationSettingsFields.tsx delete mode 100644 resources/ts/components/TokenGenerationDisabledContextProvider.tsx delete mode 100644 resources/ts/components/TokenGenerationDisabledNotice.module.css delete mode 100644 resources/ts/components/TokenGenerationDisabledNotice.tsx create mode 100644 resources/ts/contexts/BalancerDesiredStateContext.ts delete mode 100644 resources/ts/contexts/InferenceParametersContext.ts delete mode 100644 resources/ts/contexts/TokenGenerationDisabledContext.ts create mode 100644 resources/ts/embeddingParametersOf.ts delete mode 100644 resources/ts/inferenceParametersFormKeys.ts create mode 100644 resources/ts/textGenerationSettingsOf.ts diff --git a/.claude/rules/paddler-agent-decision.md b/.claude/rules/paddler-agent-decision.md new file mode 100644 index 000000000..1e3bcb480 --- /dev/null +++ b/.claude/rules/paddler-agent-decision.md @@ -0,0 +1,15 @@ +--- +paths: + - "paddler_agent_decision/**" +--- + +# Paddler Agent Decision Context + +- `paddler_agent_decision` is the agent pipeline that answers multiple-choice questions about a state document with a kev decision model: it never samples or generates text +- it lays out tokens the way kev does (`` then, per question, ` instructions ()… `), reads the post-norm hidden states of every `` and `` token, and turns them into probabilities with `paddler_agent_pointer_head` +- every decode carries exactly one sequence: hybrid (Qwen3.5) models compute differently when sequences share a micro-batch, and one sequence per decode keeps answers bit-exact and independent of load +- a single-question decision takes one sequence; a multi-question decision also reserves a question lane, which is forked from the state for every question but the last, which runs on the state sequence itself +- the capacity ledger admits decisions in FIFO order once their sequences are free and their state plus longest question fits the unified KV cache +- user text is NFC-normalized and tokenized with special-token parsing off, matching the Hugging Face tokenizer kev was trained with (`fixtures/qwen3_5_tokenizer_reference.json` is the contract) +- it builds on `paddler_agent_runtime` and `paddler_agent_pointer_head`, and never depends on another pipeline crate +- `DecisionError` is its single error enum diff --git a/.claude/rules/paddler-agent-embeddings.md b/.claude/rules/paddler-agent-embeddings.md new file mode 100644 index 000000000..c3dfc55e0 --- /dev/null +++ b/.claude/rules/paddler-agent-embeddings.md @@ -0,0 +1,10 @@ +--- +paths: + - "paddler_agent_embeddings/**" +--- + +# Paddler Agent Embeddings Context + +- `paddler_agent_embeddings` is the agent pipeline that turns batches of documents into embeddings +- it builds on `paddler_agent_runtime` and never depends on another pipeline crate +- `EmbeddingError` is its single error enum diff --git a/.claude/rules/paddler-agent-pointer-head.md b/.claude/rules/paddler-agent-pointer-head.md new file mode 100644 index 000000000..de31db9ab --- /dev/null +++ b/.claude/rules/paddler-agent-pointer-head.md @@ -0,0 +1,11 @@ +--- +paths: + - "paddler_agent_pointer_head/**" +--- + +# Paddler Agent Pointer Head Context + +- `paddler_agent_pointer_head` reads a kev pointer head from its GGUF file and turns hidden states into option probabilities +- it knows only the pointer-head GGUF contract that `paddler_kev_converter_python` writes; its tests read the committed `fixtures/*pointer_head*.gguf` files, so they double as the contract test +- it never depends on a model or a llama.cpp context; checking a pointer head against the model it serves belongs to the decision pipeline +- `PointerHeadError` is its single error enum diff --git a/.claude/rules/paddler-agent-runner.md b/.claude/rules/paddler-agent-runner.md new file mode 100644 index 000000000..1e7f88690 --- /dev/null +++ b/.claude/rules/paddler-agent-runner.md @@ -0,0 +1,10 @@ +--- +paths: + - "paddler_agent_runner/**" +--- + +# Paddler Agent Runner Context + +- `paddler_agent_runner` is the canonical way to start the agent services; `paddler_cli`, `paddler_gui`, and `paddler_tests` all start the agent through it +- it has no failure modes of its own, so it reports `ServiceThreadError` from `paddler_service_thread` instead of declaring an error enum +- it never depends on `paddler_balancer_runner` diff --git a/.claude/rules/paddler-agent-runtime.md b/.claude/rules/paddler-agent-runtime.md new file mode 100644 index 000000000..875a846dc --- /dev/null +++ b/.claude/rules/paddler-agent-runtime.md @@ -0,0 +1,11 @@ +--- +paths: + - "paddler_agent_runtime/**" +--- + +# Paddler Agent Runtime Context + +- `paddler_agent_runtime` holds what every agent inference pipeline shares: loading a model into llama.cpp, creating and warming up its contexts, running a pipeline's scheduler on its own thread, and the request every pipeline receives +- `paddler_agent_runtime` knows nothing about any particular pipeline; pipelines plug in through `PreparesSchedulerCommand` and `SchedulerCommand` +- `paddler_agent_runtime` depends only on `paddler_agent_status`, `paddler_inference_parameters`, and `paddler_messaging` among the Paddler crates +- `AgentRuntimeError` is its single error enum; pipeline crates compose it with `#[from]` diff --git a/.claude/rules/paddler-agent-status.md b/.claude/rules/paddler-agent-status.md index 4dad117e6..346ba3e95 100644 --- a/.claude/rules/paddler-agent-status.md +++ b/.claude/rules/paddler-agent-status.md @@ -7,4 +7,5 @@ paths: - `paddler_agent_status` depends only on `paddler_messaging` among the Paddler crates - `paddler_agent_status` aggregates what an agent reports about itself: its slots, its model, its download progress, and its issues -- `paddler_agent_status` is shared by `paddler_agent`, `paddler_model_source`, and `paddler_bootstrap`, so none of them needs to depend on another to report agent status +- `paddler_agent_status` is shared by `paddler_agent`, the `paddler_agent_*` pipeline crates, `paddler_model_source`, and `paddler_agent_runner`, so none of them needs to depend on another to report agent status +- `SlotGuard` holds one processing slot for as long as a request is in flight diff --git a/.claude/rules/paddler-agent-text-generation.md b/.claude/rules/paddler-agent-text-generation.md new file mode 100644 index 000000000..f189e1682 --- /dev/null +++ b/.claude/rules/paddler-agent-text-generation.md @@ -0,0 +1,10 @@ +--- +paths: + - "paddler_agent_text_generation/**" +--- + +# Paddler Agent Text Generation Context + +- `paddler_agent_text_generation` is the agent pipeline that continues conversations and raw prompts token by token: chat templates, multimodal prompts, sampling, grammars, and tool calls +- it builds on `paddler_agent_runtime` and never depends on another pipeline crate +- `TextGenerationError` is its single error enum diff --git a/.claude/rules/paddler-agent.md b/.claude/rules/paddler-agent.md index e87cf016f..8daa130a6 100644 --- a/.claude/rules/paddler-agent.md +++ b/.claude/rules/paddler-agent.md @@ -5,7 +5,7 @@ paths: # Paddler Agent Context -- `paddler_agent` is the only crate that can rely on `llama-cpp-bindings` -- agent is the only crate responsible for instantiating llama.cpp back-end, and communicating with it -- no crate can depend directly on `paddler_agent` (besides `paddler_bootstrap`, and other test related crates), they need to use `paddler_messaging` instead +- only the agent crates (`paddler_agent` and the `paddler_agent_*` pipeline crates: `paddler_agent_runtime`, `paddler_agent_text_generation`, `paddler_agent_embeddings`, `paddler_agent_decision`, `paddler_agent_pointer_head`) can rely on `llama-cpp-bindings`; balancer-side crates never do +- `paddler_agent` connects to the balancer, reconciles the desired state, and runs the inference pipeline that serves it; each pipeline lives in its own `paddler_agent_*` crate +- no crate can depend directly on `paddler_agent` (besides `paddler_agent_runner`, and other test related crates), they need to use `paddler_messaging` instead diff --git a/.claude/rules/paddler-balancer-runner.md b/.claude/rules/paddler-balancer-runner.md new file mode 100644 index 000000000..a9fd5f570 --- /dev/null +++ b/.claude/rules/paddler-balancer-runner.md @@ -0,0 +1,10 @@ +--- +paths: + - "paddler_balancer_runner/**" +--- + +# Paddler Balancer Runner Context + +- `paddler_balancer_runner` is the canonical way to start the balancer services; `paddler_cli`, `paddler_gui`, and `paddler_tests` all start the balancer through it +- `BalancerRunnerError` is its single error enum +- it never depends on `paddler_agent_runner` (its tests may, to exercise a real agent connecting) diff --git a/.claude/rules/paddler-balancer.md b/.claude/rules/paddler-balancer.md index 1e73b0236..325713b24 100644 --- a/.claude/rules/paddler-balancer.md +++ b/.claude/rules/paddler-balancer.md @@ -7,4 +7,19 @@ paths: - `paddler_balancer` crate is responsible for starting inference, and management servers - Paddler Agents connect to the balancer in order to handle the requests that the balancer dispatches -- `paddler_balancer` provides compatibility services that expose vendor-compatible APIs (for example OpenAI compatibility server) +- `paddler_balancer` provides compatibility services that expose vendor-compatible APIs: OpenAI compatibility in text-generation mode (built on `paddler_openai_translation`), and TypeSafe System One compatibility in decision mode (built on `paddler_typesafe_translation`) + +# Compatibility Layers + +Every compatibility layer follows the same conventions, so adding a vendor never means inventing a new shape: + +- the HTTP layer lives in `compatibility/_service/`; vendor DTOs, request translation, response building, and generation state live in a pure `paddler__translation` crate with no actix or balancer dependency and a single `TranslationError` +- each layer is a unit struct `CompatibilityLayer` implementing `ServesCompatibilityLayer`; it runs inside the shared `CompatibilityService`, configured by `CompatibilityServiceConfiguration`, which owns CORS, the request id middleware, `/health`, and the worker count +- paths come from `ApiPath` and headers from `Header`, which always declares `REQUEST_ID`; tests and the harness import these constants instead of repeating string literals +- `attach_request_id` echoes the client's `Header::REQUEST_ID` or generates one, and CORS exposes it +- the wire error body is `ErrorBody` with `into_http_response(self, StatusCode)`; malformed JSON is refused through `_json_config()` with the vendor's client-error status (OpenAI 400, TypeSafe 422) +- routes relay agent results through `CompatibilityAppData::agent_result_stream`, which yields `AgentResultStreamEvent`; a response of another inference mode is refused as `AgentRelayError::MessageNotRelayable` +- buffered responders are named `_http_response`, streaming responders `_sse_response` +- server-side statuses come only from `UpstreamFailure` (500 agent failed, 502 relay failed, 503 unavailable, 504 timed out, and the JSON-RPC code mapping); client-error statuses stay vendor-specific +- a route is `async fn respond(..) -> HttpResponse`, registered by `pub fn _(cfg)` +- integration tests are named `__` diff --git a/.claude/rules/paddler-bootstrap.md b/.claude/rules/paddler-bootstrap.md deleted file mode 100644 index 9c2088ad0..000000000 --- a/.claude/rules/paddler-bootstrap.md +++ /dev/null @@ -1,11 +0,0 @@ ---- -paths: - - "paddler_bootstrap/**" ---- - -# Paddler Bootstrap Context - -- `paddler_bootstrap` is used both by `paddler_cli`, `paddler_tests`, and `paddler_gui` -- `paddler_bootstrap` combines both `paddler_agent`, and `paddler_balancer`, and provides a unified entry point -- `paddler_bootstrap` is the canonical way to start both core paddler services (balancer, and agent) -- `paddler_bootstrap` is the source of truth on how to start Paddler services diff --git a/.claude/rules/paddler-cli.md b/.claude/rules/paddler-cli.md index f44aae2ce..5dda46267 100644 --- a/.claude/rules/paddler-cli.md +++ b/.claude/rules/paddler-cli.md @@ -5,6 +5,6 @@ paths: # Paddler CLI Context -- `paddler_cli` is intended to combine both `paddler_agent`, and `paddler_balancer` through `paddler_bootstrap` +- `paddler_cli` is intended to combine both `paddler_agent`, and `paddler_balancer` through `paddler_balancer_runner` and `paddler_agent_runner` - `paddler_cli` is intended to provide a command line Paddler binary, to be used in server infrastructure - `paddler_cli` is intended to encapsulate both balancer, and agent in a single binary, for the sake of ease of deployment diff --git a/.claude/rules/paddler-client-javascript.md b/.claude/rules/paddler-client-javascript.md index 28b7316a2..84bd9a847 100644 --- a/.claude/rules/paddler-client-javascript.md +++ b/.claude/rules/paddler-client-javascript.md @@ -9,4 +9,4 @@ paths: - It must provide a way to connect to only, specifically balancer's inference address (without the need to connect to management service at the same time) - It must provide a way to connect to only, specifically balancer's management address (without the need to connect to inference service at the same time) - Paddler client must support all Paddler's native endpoints -- It must not implement OpenAI compatibility client +- It must not implement OpenAI or TypeSafe compatibility clients diff --git a/.claude/rules/paddler-client-python.md b/.claude/rules/paddler-client-python.md deleted file mode 100644 index 076b143d9..000000000 --- a/.claude/rules/paddler-client-python.md +++ /dev/null @@ -1,12 +0,0 @@ ---- -paths: - - "paddler_client_python/**" ---- - -# Paddler Client Context - -- `paddler_client_python` provides JavaScript client that connects to `paddler_balancer` -- It must provide a way to connect to only, specifically balancer's inference address (without the need to connect to management service at the same time) -- It must provide a way to connect to only, specifically balancer's management address (without the need to connect to inference service at the same time) -- Paddler client must support all Paddler's native endpoints -- It must not implement OpenAI compatibility client diff --git a/.claude/rules/paddler-client.md b/.claude/rules/paddler-client.md index 1f93341b5..b721b67bf 100644 --- a/.claude/rules/paddler-client.md +++ b/.claude/rules/paddler-client.md @@ -9,4 +9,4 @@ paths: - It must provide a way to connect to only, specifically balancer's inference address (without the need to connect to management service at the same time) - It must provide a way to connect to only, specifically balancer's management address (without the need to connect to inference service at the same time) - Paddler client must support all Paddler's native endpoints -- It must not implement OpenAI compatibility client +- It must not implement OpenAI or TypeSafe compatibility clients diff --git a/.claude/rules/paddler-kev-converter-python.md b/.claude/rules/paddler-kev-converter-python.md new file mode 100644 index 000000000..25096dbd0 --- /dev/null +++ b/.claude/rules/paddler-kev-converter-python.md @@ -0,0 +1,15 @@ +--- +paths: + - "paddler_kev_converter_python/**" + - "paddler_llama_cpp_converter_python/**" +--- + +# Paddler Kev Converter Context + +- `paddler_kev_converter_python` turns a kev decision checkpoint into the files Paddler serves: a `Qwen3_5ForCausalLM` Hugging Face directory with the LoRA merged in fp32 and the base's own tokenizer files, plus the pointer-head GGUF +- it also writes the committed reference fixtures (synthetic pointer heads, Hugging Face tokenizer ids, TypeSafe renderings and answers, and the Kev-0.8B fp32 parity reference) through the `fixtures/*` Makefile targets; regenerate them with make, never by hand +- it reuses kev itself (`kev.checkpoint`, `kev.model`, `kev.api`), pinned to an exact commit; `stubs/kev` declares the typed surface of the kev APIs it calls and must follow the pinned kev +- it refuses checkpoints it cannot convert exactly (full weights, non-fp32 backbones, option isolation, trained token embeddings, bases other than `qwen3_5_text`) with one exception class per refusal +- `paddler_llama_cpp_converter_python` only pins the runtime of llama.cpp's `convert_hf_to_gguf.py`; the script itself comes from the llama.cpp sources the `llama-cpp-bindings-sys` crate ships, so the GGUF always matches the llama.cpp Paddler builds +- the two converters need separate environments: kev needs transformers 5, llama.cpp's converter pins transformers 4 +- the pointer-head GGUF contract: architecture `pointer_head`; F32 tensors `pointer_head.{query,key}.{weight,bias}` (the hidden size comes from their shapes); `pointer_head.temperature`, and `pointer_head.delimiter.{state,question,option_start,option_end,decide}` as token strings diff --git a/.claude/rules/paddler-openai-translation.md b/.claude/rules/paddler-openai-translation.md new file mode 100644 index 000000000..a14e4df46 --- /dev/null +++ b/.claude/rules/paddler-openai-translation.md @@ -0,0 +1,13 @@ +--- +paths: + - "paddler_openai_translation/**" +--- + +# Paddler OpenAI Translation Context + +- `paddler_openai_translation` translates OpenAI Chat Completions (`POST /v1/chat/completions`) and Responses (`POST /v1/responses`) requests into Paddler conversation requests, and generated tokens back into OpenAI completions, chunks, and stream events +- `ChatCompletionRequest::translate` and `ResponsesRequest::translate` produce a translated request whose delivery is either buffered (a header that `CompletesGeneration` turns into the whole response) or streamed (`ChatCompletionStream`, `ResponsesStream`) +- `GenerationEvent::from(GeneratedTokenResult)` is the single classification of generated tokens into output, the finish, and failures with their `GenerationFailureCause` +- a chat completion's `id` is the balancer's request id, so client-visible ids correlate with balancer logs +- it never depends on actix or the balancer; the OpenAI HTTP service builds on it +- `OpenAITranslationError` is its single error enum diff --git a/.claude/rules/paddler-service-thread.md b/.claude/rules/paddler-service-thread.md new file mode 100644 index 000000000..f03ea3b09 --- /dev/null +++ b/.claude/rules/paddler-service-thread.md @@ -0,0 +1,10 @@ +--- +paths: + - "paddler_service_thread/**" +--- + +# Paddler Service Thread Context + +- `paddler_service_thread` runs a bundle of trzcina services on a dedicated thread until it is cancelled, and reports how that thread ended +- `ServiceThreadError` is its single error enum +- it knows nothing about the balancer or the agent; `paddler_balancer_runner` and `paddler_agent_runner` build on it diff --git a/.claude/rules/paddler-test-cluster-harness.md b/.claude/rules/paddler-test-cluster-harness.md index 9c82fe60d..b754681a3 100644 --- a/.claude/rules/paddler-test-cluster-harness.md +++ b/.claude/rules/paddler-test-cluster-harness.md @@ -5,8 +5,14 @@ paths: # Paddler Test Cluster Harness Context -- `paddler_test_cluster_harness` provides common test harness to be used with `paddler_bootstrap` tests, `paddler_cli_tests`, `paddler_gui` tests, `paddler_gui_tests`, `paddler_opencode_tests`, and `paddler_tests` +- `paddler_test_cluster_harness` provides common test harness to be used with `paddler_balancer_runner` tests, `paddler_agent_runner` tests, `paddler_cli_tests`, `paddler_gui` tests, `paddler_gui_tests`, `paddler_opencode_tests`, and `paddler_tests` # OpenAI Compatibility Testing - To stay objective, we must not implement our own OpenAI client, instead we need to use a vetted 3rd party (preferably official OpenAI API client) + +# TypeSafe Compatibility Testing + +- TypeSafe publishes no Rust SDK, so the harness's `TypeSafeApiClient` is a thin `reqwest` client built on the balancer's `TypeSafeApiPath` and `TypeSafeHeader` +- the official `typesafe-sdk` is exercised by `paddler_typesafe_client_python_test`, which keeps the compatibility claim objective +- both layers expose the same harness surface: `RunningBalancer::compat__addr()`, `compat__base_url()`, and `Cluster::compat__health_client()` diff --git a/.claude/rules/paddler-tests.md b/.claude/rules/paddler-tests.md index 3bca9060c..ddc88bb38 100644 --- a/.claude/rules/paddler-tests.md +++ b/.claude/rules/paddler-tests.md @@ -7,3 +7,4 @@ paths: - `paddler_tests` contains Paddler integration tests - `paddler_tests` contain utilities related to the integration tests +- its `run_with_test_cluster` binary starts a test cluster from a named preset and runs a command with the cluster's URLs in the environment; the vendor SDK suites (`paddler_openai_client_python_test`, `paddler_typesafe_client_python_test`) run inside it, so cluster setup lives only in Rust diff --git a/.claude/rules/paddler-typesafe-translation.md b/.claude/rules/paddler-typesafe-translation.md new file mode 100644 index 000000000..1f8b5d01a --- /dev/null +++ b/.claude/rules/paddler-typesafe-translation.md @@ -0,0 +1,13 @@ +--- +paths: + - "paddler_typesafe_translation/**" +--- + +# Paddler TypeSafe Translation Context + +- `paddler_typesafe_translation` translates TypeSafe System One requests (`POST /v1/systemone`) into Paddler decisions, and decision answers back into TypeSafe answers +- it reproduces kev's `kev.api` exactly: JSON content renders with Python `str()` semantics (Python float repr, `True`/`False`, `lstrip`), noul/choice/score questions become option lists, and answers carry kev's confidences rounded to 4 decimals +- `fixtures/typesafe_reference.json`, written by `paddler_kev_converter_python` from `kev.api`, is the golden contract its tests check +- question and criteria order follow the request, which `serde_json`'s `preserve_order` keeps +- it never depends on actix or the balancer; the TypeSafe HTTP service builds on it +- `TypeSafeTranslationError` is its single error enum diff --git a/.claude/skills/running-all-tests/SKILL.md b/.claude/skills/running-all-tests/SKILL.md index b541bb8fa..791bcf0f1 100644 --- a/.claude/skills/running-all-tests/SKILL.md +++ b/.claude/skills/running-all-tests/SKILL.md @@ -32,25 +32,25 @@ Copy this checklist and tick each item as the suite completes: - [ ] JS client - [ ] JS client LLM - [ ] Python lint -- [ ] Python client -- [ ] Python client LLM - [ ] OpenAI Python client LLM +- [ ] TypeSafe Python client LLM +- [ ] kev converter LLM - [ ] Rust unit - [ ] Rust integration ``` -| # | Suite | Command (from the repo root) | -|---|--------------------------|-------------------------------------------------------| -| 1 | JS client | `TEST_DEVICE=$DEVICE make test.client.js` | -| 2 | JS client LLM | `TEST_DEVICE=$DEVICE make test.client.js.llm` | -| 3 | Python lint | `make lint.client.python lint.openai.python` | -| 4 | Python client | `TEST_DEVICE=$DEVICE make test.client.python` | -| 5 | Python client LLM | `TEST_DEVICE=$DEVICE make test.client.python.llm` | -| 6 | OpenAI Python client LLM | `TEST_DEVICE=$DEVICE make test.openai.python.llm` | -| 7 | Rust unit | `TEST_DEVICE=$DEVICE make test.unit` | -| 8 | Rust integration | `TEST_DEVICE=$DEVICE make test.integration` | - -Run them in this order. Cheap suites (1, 3, 4, 7) surface bugs quickly; the GPU-bound suites (2, 5, 6, 8) load models. +| # | Suite | Command (from the repo root) | +|---|----------------------------|-----------------------------------------------------------------------| +| 1 | JS client | `TEST_DEVICE=$DEVICE make test.client.js` | +| 2 | JS client LLM | `TEST_DEVICE=$DEVICE make test.client.js.llm` | +| 3 | Python lint | `make lint.openai.python lint.typesafe.python lint.kev_converter.python` | +| 4 | OpenAI Python client LLM | `TEST_DEVICE=$DEVICE make test.openai.python.llm` | +| 5 | TypeSafe Python client LLM | `TEST_DEVICE=$DEVICE make test.typesafe.python.llm` | +| 6 | kev converter LLM | `make test.kev_converter.python.llm` | +| 7 | Rust unit | `TEST_DEVICE=$DEVICE make test.unit` | +| 8 | Rust integration | `TEST_DEVICE=$DEVICE make test.integration` | + +Run them in this order. Cheap suites (1, 3, 7) surface bugs quickly; the suites that load models (2, 4, 5, 6, 8) come after them. On NixOS the pinned `ruff` wheel is dynamically linked, so suite 3 needs `nix-ld`. diff --git a/.claude/skills/running-coverage/SKILL.md b/.claude/skills/running-coverage/SKILL.md index 607ee65f9..bd55020ae 100644 --- a/.claude/skills/running-coverage/SKILL.md +++ b/.claude/skills/running-coverage/SKILL.md @@ -30,15 +30,15 @@ Copy this checklist and tick each item as the suite completes: ``` - [ ] Rust -- [ ] Python client - [ ] JS client +- [ ] kev converter ``` -| # | Suite | Command (from the repo root) | -|---|---------------|--------------------------------------------------------| -| 1 | Rust | `TEST_DEVICE=$DEVICE make test.coverage` | -| 2 | Python client | `TEST_DEVICE=$DEVICE make test.client.python.coverage` | -| 3 | JS client | `TEST_DEVICE=$DEVICE make test.client.js.coverage` | +| # | Suite | Command (from the repo root) | +|---|---------------|----------------------------------------------------| +| 1 | Rust | `TEST_DEVICE=$DEVICE make test.coverage` | +| 2 | JS client | `TEST_DEVICE=$DEVICE make test.client.js.coverage` | +| 3 | kev converter | `make test.kev_converter.python.llm` | ## Step 3: rules during the run diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 6497c8006..cf79b21f4 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -108,19 +108,15 @@ jobs: - name: Install Poetry run: pip install poetry==2.4.3 - - name: Lint the Python client - run: make lint.client.python - - name: Lint the OpenAI Python client suite run: make lint.openai.python - python: - name: python (${{ matrix.python-version }}) - needs: paddler-binary + - name: Lint the TypeSafe Python client suite + run: make lint.typesafe.python + + kev-converter-lint: + name: kev converter lint runs-on: ubuntu-latest - strategy: - matrix: - python-version: ["3.11", "3.13"] steps: - name: checkout code @@ -129,21 +125,10 @@ jobs: - name: Set up Python uses: actions/setup-python@v5 with: - python-version: ${{ matrix.python-version }} + python-version: "3.13" - name: Install Poetry run: pip install poetry==2.4.3 - - name: Download the Paddler binary - uses: actions/download-artifact@v4 - with: - name: paddler-binary - path: target/debug - - - name: Mark the downloaded Paddler binary as built and executable - run: | - chmod +x target/debug/paddler - touch target/debug/paddler - - - name: Run Python client tests - run: make test.client.python + - name: Lint the kev converter + run: make lint.kev_converter.python diff --git a/Cargo.lock b/Cargo.lock index e60df7353..930cfaa30 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -120,7 +120,6 @@ version = "2.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e5f794807f82bbd36430c12cd600c73bbab0f52fdde4f0ed49978df113f4807f" dependencies = [ - "actix-macros", "futures-core", "tokio", ] @@ -3152,6 +3151,9 @@ dependencies = [ "icu_properties", "icu_provider", "smallvec", + "utf16_iter", + "utf8_iter", + "write16", "zerovec", ] @@ -3757,8 +3759,6 @@ checksum = "11d3d7f243d5c5a8b9bb5d6dd2b1602c0cb0b9db1621bafc7ed66e35ff9fe092" [[package]] name = "llama-cpp-bindings" version = "0.16.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b0cab532a93dac3f03c6e3923fd24d91434bdf4142dcec89af08842a5e791ad7" dependencies = [ "encoding_rs", "enumflags2", @@ -3781,8 +3781,6 @@ dependencies = [ [[package]] name = "llama-cpp-bindings-build" version = "0.16.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b281e0ae2b5ad140756ebe47c5e00e5e17f1a08e890cdb7601d856a2f2da59b4" dependencies = [ "bindgen", "cc", @@ -3795,8 +3793,6 @@ dependencies = [ [[package]] name = "llama-cpp-bindings-sys" version = "0.16.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7fe502d4a74ec5d93a2a78917c9c4223826e2b9466b2c5b869357e51fb7116a8" dependencies = [ "llama-cpp-bindings-build", ] @@ -3804,8 +3800,6 @@ dependencies = [ [[package]] name = "llama-cpp-bindings-types" version = "0.16.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "002e2c1fc09748fb2b9914c660a8661368cfef18d1b205d2fd14f33a979e5b48" dependencies = [ "serde", "serde_json", @@ -3815,8 +3809,6 @@ dependencies = [ [[package]] name = "llama-cpp-error-recorder" version = "0.16.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d181159c5b293f11fddb23d3fd012277110a8405e24a6c0a454329ce859627fe" dependencies = [ "log", ] @@ -3824,8 +3816,6 @@ dependencies = [ [[package]] name = "llama-cpp-ffi-status" version = "0.16.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7d1c9ab4956a5f38e11df94e3064a77af2dc3d66ec900720e9409d43435e315e" dependencies = [ "llama-cpp-bindings-sys", "thiserror 2.0.18", @@ -3834,8 +3824,6 @@ dependencies = [ [[package]] name = "llama-cpp-gbnf" version = "0.16.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "484337250ef83f370320f9a262cb98074fc7777545b13363dfa50925b518c785" dependencies = [ "llama-cpp-bindings-sys", "llama-cpp-ffi-status", @@ -3845,14 +3833,10 @@ dependencies = [ [[package]] name = "llama-cpp-log-decoder" version = "0.16.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "af82bb7e5f57250f5d4cf0b7ae93730f0f38676367360eb512994c1c823f8132" [[package]] name = "llama-cpp-wrapper-sources" version = "0.16.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4c490620f948a5f26cde3ce443c70fe9e91d04042bd5a195d0e6924340566130" dependencies = [ "serde", "serde_json", @@ -4861,55 +4845,108 @@ dependencies = [ [[package]] name = "paddler_agent" -version = "4.1.0" +version = "5.0.0" dependencies = [ "anyhow", - "async-stream", "async-trait", - "bytes 1.11.1", - "dashmap", - "encoding_rs", "futures 0.3.32", "futures-util", - "hf-hub", - "indoc", - "llama-cpp-bindings", - "llama-cpp-bindings-sys", - "llama-cpp-bindings-types", "log", - "minijinja", - "minijinja-contrib", - "nanoid", + "paddler_agent_decision", + "paddler_agent_embeddings", + "paddler_agent_runtime", "paddler_agent_status", - "paddler_cache_dir", - "paddler_download_manager", - "paddler_image_decoder", + "paddler_agent_text_generation", "paddler_inference_parameters", - "paddler_local_http_fixture", "paddler_messaging", "paddler_model_source", "paddler_request_registry", - "paddler_tool_call_validator", - "parking_lot", - "rand 0.9.4", - "reqwest 0.12.28", - "serde", "serde_json", - "shellexpand", "tempfile", "thiserror 2.0.18", "tokio", - "tokio-stream", - "tokio-test", "tokio-tungstenite", "tokio-util", "trzcina", - "url", +] + +[[package]] +name = "paddler_agent_decision" +version = "5.0.0" +dependencies = [ + "icu_normalizer", + "llama-cpp-bindings", + "log", + "paddler_agent_pointer_head", + "paddler_agent_runtime", + "paddler_agent_status", + "paddler_inference_parameters", + "paddler_messaging", + "thiserror 2.0.18", + "tokio", + "tokio-util", +] + +[[package]] +name = "paddler_agent_embeddings" +version = "5.0.0" +dependencies = [ + "llama-cpp-bindings", + "log", + "paddler_agent_runtime", + "paddler_agent_status", + "paddler_inference_parameters", + "paddler_messaging", + "thiserror 2.0.18", + "tokio", + "tokio-util", +] + +[[package]] +name = "paddler_agent_pointer_head" +version = "5.0.0" +dependencies = [ + "llama-cpp-bindings", + "thiserror 2.0.18", +] + +[[package]] +name = "paddler_agent_runner" +version = "5.0.0" +dependencies = [ + "anyhow", + "async-trait", + "nanoid", + "paddler_agent", + "paddler_agent_runtime", + "paddler_agent_status", + "paddler_messaging", + "paddler_service_thread", + "paddler_test_cluster_harness", + "tokio", + "tokio-util", + "trzcina", +] + +[[package]] +name = "paddler_agent_runtime" +version = "5.0.0" +dependencies = [ + "llama-cpp-bindings", + "llama-cpp-bindings-sys", + "log", + "paddler_agent_status", + "paddler_inference_parameters", + "paddler_messaging", + "parking_lot", + "thiserror 2.0.18", + "tokio", + "tokio-util", ] [[package]] name = "paddler_agent_status" -version = "4.1.0" +version = "5.0.0" dependencies = [ "dashmap", "paddler_messaging", @@ -4918,9 +4955,32 @@ dependencies = [ "tokio-test", ] +[[package]] +name = "paddler_agent_text_generation" +version = "5.0.0" +dependencies = [ + "encoding_rs", + "llama-cpp-bindings", + "log", + "minijinja", + "minijinja-contrib", + "paddler_agent_runtime", + "paddler_agent_status", + "paddler_image_decoder", + "paddler_inference_parameters", + "paddler_messaging", + "paddler_tool_call_validator", + "rand 0.9.4", + "serde", + "serde_json", + "thiserror 2.0.18", + "tokio", + "tokio-util", +] + [[package]] name = "paddler_balancer" -version = "4.1.0" +version = "5.0.0" dependencies = [ "actix-cors", "actix-web", @@ -4944,9 +5004,12 @@ dependencies = [ "paddler_inference_parameters", "paddler_messaging", "paddler_openai_response_format_validator", + "paddler_openai_translation", "paddler_request_registry", "paddler_state_database", + "paddler_typesafe_translation", "parking_lot", + "rand 0.9.4", "rust-embed", "serde", "serde_json", @@ -4961,19 +5024,16 @@ dependencies = [ ] [[package]] -name = "paddler_bootstrap" -version = "4.1.0" +name = "paddler_balancer_runner" +version = "5.0.0" dependencies = [ - "actix-web", "anyhow", "async-trait", - "log", - "nanoid", - "paddler_agent", - "paddler_agent_status", + "paddler_agent_runner", "paddler_balancer", "paddler_inference_parameters", "paddler_messaging", + "paddler_service_thread", "paddler_state_database", "paddler_test_cluster_harness", "reqwest 0.12.28", @@ -4987,7 +5047,7 @@ dependencies = [ [[package]] name = "paddler_cache_dir" -version = "4.1.0" +version = "5.0.0" dependencies = [ "fslock", "sha2", @@ -4999,17 +5059,18 @@ dependencies = [ [[package]] name = "paddler_cli" -version = "4.1.0" +version = "5.0.0" dependencies = [ - "actix-rt", "actix-web", "anyhow", "async-trait", "clap", "command_handler", "env_logger", + "paddler_agent_runner", "paddler_balancer", - "paddler_bootstrap", + "paddler_balancer_runner", + "paddler_service_thread", "paddler_state_database", "tokio-util", "trzcina", @@ -5017,7 +5078,7 @@ dependencies = [ [[package]] name = "paddler_cli_tests" -version = "4.1.0" +version = "5.0.0" dependencies = [ "anyhow", "async-trait", @@ -5040,7 +5101,7 @@ dependencies = [ [[package]] name = "paddler_client" -version = "4.1.0" +version = "5.0.0" dependencies = [ "bytes 1.11.1", "futures-util", @@ -5063,7 +5124,7 @@ dependencies = [ [[package]] name = "paddler_download_manager" -version = "4.1.0" +version = "5.0.0" dependencies = [ "bytes 1.11.1", "futures-util", @@ -5080,7 +5141,7 @@ dependencies = [ [[package]] name = "paddler_gui" -version = "4.1.0" +version = "5.0.0" dependencies = [ "async-stream", "clap", @@ -5090,10 +5151,12 @@ dependencies = [ "if-addrs", "log", "open", + "paddler_agent_runner", "paddler_balancer", - "paddler_bootstrap", + "paddler_balancer_runner", "paddler_inference_parameters", "paddler_messaging", + "paddler_service_thread", "paddler_state_database", "paddler_test_cluster_harness", "statum", @@ -5105,14 +5168,16 @@ dependencies = [ [[package]] name = "paddler_gui_tests" -version = "4.1.0" +version = "5.0.0" dependencies = [ "futures 0.3.32", "iced_test", + "paddler_agent_runner", "paddler_balancer", - "paddler_bootstrap", + "paddler_balancer_runner", "paddler_gui", "paddler_messaging", + "paddler_service_thread", "paddler_test_cluster_harness", "reqwest 0.12.28", "thiserror 2.0.18", @@ -5123,7 +5188,7 @@ dependencies = [ [[package]] name = "paddler_image_decoder" -version = "4.1.0" +version = "5.0.0" dependencies = [ "base64", "image", @@ -5135,7 +5200,7 @@ dependencies = [ [[package]] name = "paddler_inference_parameters" -version = "4.1.0" +version = "5.0.0" dependencies = [ "serde", "serde_json", @@ -5144,7 +5209,7 @@ dependencies = [ [[package]] name = "paddler_local_http_fixture" -version = "4.1.0" +version = "5.0.0" dependencies = [ "http 1.4.0", "httparse", @@ -5153,7 +5218,7 @@ dependencies = [ [[package]] name = "paddler_messaging" -version = "4.1.0" +version = "5.0.0" dependencies = [ "base64", "encoding_rs", @@ -5170,7 +5235,7 @@ dependencies = [ [[package]] name = "paddler_model_source" -version = "4.1.0" +version = "5.0.0" dependencies = [ "async-trait", "hf-hub", @@ -5190,7 +5255,7 @@ dependencies = [ [[package]] name = "paddler_openai_response_format_validator" -version = "4.1.0" +version = "5.0.0" dependencies = [ "jsonschema", "serde_json", @@ -5198,9 +5263,23 @@ dependencies = [ "yaml-rust2", ] +[[package]] +name = "paddler_openai_translation" +version = "5.0.0" +dependencies = [ + "llama-cpp-bindings-types", + "nanoid", + "paddler_inference_parameters", + "paddler_messaging", + "paddler_openai_response_format_validator", + "serde", + "serde_json", + "thiserror 2.0.18", +] + [[package]] name = "paddler_opencode_tests" -version = "4.1.0" +version = "5.0.0" dependencies = [ "anyhow", "paddler_test_cluster_harness", @@ -5214,19 +5293,32 @@ dependencies = [ [[package]] name = "paddler_request_registry" -version = "4.1.0" +version = "5.0.0" dependencies = [ "dashmap", "tokio", ] +[[package]] +name = "paddler_service_thread" +version = "5.0.0" +dependencies = [ + "actix-web", + "anyhow", + "async-trait", + "log", + "thiserror 2.0.18", + "tokio", + "tokio-util", + "trzcina", +] + [[package]] name = "paddler_state_database" -version = "4.1.0" +version = "5.0.0" dependencies = [ "async-trait", "log", - "paddler_inference_parameters", "paddler_messaging", "parking_lot", "serde", @@ -5240,7 +5332,7 @@ dependencies = [ [[package]] name = "paddler_test_cluster_harness" -version = "4.1.0" +version = "5.0.0" dependencies = [ "anyhow", "async-openai", @@ -5254,6 +5346,7 @@ dependencies = [ "paddler_inference_parameters", "paddler_messaging", "quick-xml", + "reqwest 0.12.28", "serde", "serde_json", "tempfile", @@ -5267,17 +5360,24 @@ dependencies = [ [[package]] name = "paddler_tests" -version = "4.1.0" +version = "5.0.0" dependencies = [ "anyhow", "async-openai", "async-trait", + "clap", "futures-util", + "hf-hub", "http 1.4.0", + "llama-cpp-bindings", "llama-cpp-bindings-types", "log", + "paddler_agent_decision", + "paddler_agent_runner", + "paddler_agent_runtime", + "paddler_agent_status", "paddler_balancer", - "paddler_bootstrap", + "paddler_balancer_runner", "paddler_client", "paddler_inference_parameters", "paddler_local_http_fixture", @@ -5286,17 +5386,17 @@ dependencies = [ "paddler_state_database", "paddler_test_cluster_harness", "reqwest 0.12.28", + "serde", "serde_json", "tempfile", "tokio", "tokio-tungstenite", "tokio-util", - "trzcina", ] [[package]] name = "paddler_tool_call_validator" -version = "4.1.0" +version = "5.0.0" dependencies = [ "jsonschema", "llama-cpp-bindings-types", @@ -5305,6 +5405,16 @@ dependencies = [ "thiserror 2.0.18", ] +[[package]] +name = "paddler_typesafe_translation" +version = "5.0.0" +dependencies = [ + "paddler_messaging", + "serde", + "serde_json", + "thiserror 2.0.18", +] + [[package]] name = "parking" version = "2.2.1" @@ -7641,6 +7751,12 @@ version = "0.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9" +[[package]] +name = "utf16_iter" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8232dd3cdaed5356e0f716d285e4b40b932ac434100fe9b7e0e8e935b9e6246" + [[package]] name = "utf8_iter" version = "1.0.4" @@ -8833,6 +8949,12 @@ dependencies = [ "wasmparser", ] +[[package]] +name = "write16" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1890f4022759daae28ed4fe62859b1236caebfc61ede2f63ed4e695f3f6d936" + [[package]] name = "writeable" version = "0.6.3" diff --git a/Cargo.toml b/Cargo.toml index 675948720..10d00ba32 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,9 +1,15 @@ [workspace] members = [ "paddler_agent", + "paddler_agent_decision", + "paddler_agent_embeddings", + "paddler_agent_pointer_head", + "paddler_agent_runner", + "paddler_agent_runtime", "paddler_agent_status", + "paddler_agent_text_generation", "paddler_balancer", - "paddler_bootstrap", + "paddler_balancer_runner", "paddler_cache_dir", "paddler_cli", "paddler_cli_tests", @@ -17,12 +23,15 @@ members = [ "paddler_messaging", "paddler_model_source", "paddler_openai_response_format_validator", + "paddler_openai_translation", "paddler_opencode_tests", "paddler_request_registry", + "paddler_service_thread", "paddler_state_database", "paddler_test_cluster_harness", "paddler_tests", "paddler_tool_call_validator", + "paddler_typesafe_translation", ] resolver = "2" @@ -33,11 +42,10 @@ edition = "2024" homepage = "https://paddler.intentee.com/" license = "Apache-2.0" repository = "https://github.com/intentee/paddler" -version = "4.1.0" +version = "5.0.0" [workspace.dependencies] actix-cors = "0.7" -actix-rt = "2.11" actix-web = "4.13" actix-web-lab = "0.26" actix-ws = "0.3" @@ -62,12 +70,13 @@ headers = "0.4" hf-hub = { version = "0.4", features = ["tokio"] } http = "1.4" httparse = "1.10" +icu_normalizer = "2.2" image = "0.25" indoc = "2.0" jsonschema = { version = "0.37", default-features = false } -llama-cpp-bindings = "0.16" -llama-cpp-bindings-sys = "0.16" -llama-cpp-bindings-types = "0.16" +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.0" @@ -101,26 +110,35 @@ thiserror = "2.0" trzcina = "0.3" url = { version = "2.5", features = ["serde"] } yaml-rust2 = "0.11" -paddler_agent = { version = "4.1", path = "paddler_agent" } -paddler_agent_status = { version = "4.1", path = "paddler_agent_status" } -paddler_balancer = { version = "4.1", path = "paddler_balancer" } -paddler_bootstrap = { version = "4.1", path = "paddler_bootstrap" } -paddler_cache_dir = { version = "4.1", path = "paddler_cache_dir" } -paddler_cli = { version = "4.1", path = "paddler_cli" } -paddler_client = { version = "4.1", path = "paddler_client" } -paddler_download_manager = { version = "4.1", path = "paddler_download_manager" } -paddler_gui = { version = "4.1", path = "paddler_gui" } -paddler_image_decoder = { version = "4.1", path = "paddler_image_decoder" } -paddler_inference_parameters = { version = "4.1", path = "paddler_inference_parameters" } -paddler_local_http_fixture = { version = "4.1", path = "paddler_local_http_fixture" } -paddler_messaging = { version = "4.1", path = "paddler_messaging" } -paddler_model_source = { version = "4.1", path = "paddler_model_source" } -paddler_openai_response_format_validator = { version = "4.1", path = "paddler_openai_response_format_validator" } -paddler_request_registry = { version = "4.1", path = "paddler_request_registry" } -paddler_state_database = { version = "4.1", path = "paddler_state_database" } -paddler_test_cluster_harness = { version = "4.1", path = "paddler_test_cluster_harness" } -paddler_tests = { version = "4.1", path = "paddler_tests" } -paddler_tool_call_validator = { version = "4.1", path = "paddler_tool_call_validator" } +paddler_agent = { version = "5.0", path = "paddler_agent" } +paddler_agent_decision = { version = "5.0", path = "paddler_agent_decision" } +paddler_agent_embeddings = { version = "5.0", path = "paddler_agent_embeddings" } +paddler_agent_pointer_head = { version = "5.0", path = "paddler_agent_pointer_head" } +paddler_agent_runner = { version = "5.0", path = "paddler_agent_runner" } +paddler_agent_runtime = { version = "5.0", path = "paddler_agent_runtime" } +paddler_agent_status = { version = "5.0", path = "paddler_agent_status" } +paddler_agent_text_generation = { version = "5.0", path = "paddler_agent_text_generation" } +paddler_balancer = { version = "5.0", path = "paddler_balancer" } +paddler_balancer_runner = { version = "5.0", path = "paddler_balancer_runner" } +paddler_cache_dir = { version = "5.0", path = "paddler_cache_dir" } +paddler_cli = { version = "5.0", path = "paddler_cli" } +paddler_client = { version = "5.0", path = "paddler_client" } +paddler_download_manager = { version = "5.0", path = "paddler_download_manager" } +paddler_gui = { version = "5.0", path = "paddler_gui" } +paddler_image_decoder = { version = "5.0", path = "paddler_image_decoder" } +paddler_inference_parameters = { version = "5.0", path = "paddler_inference_parameters" } +paddler_local_http_fixture = { version = "5.0", path = "paddler_local_http_fixture" } +paddler_messaging = { version = "5.0", path = "paddler_messaging" } +paddler_model_source = { version = "5.0", path = "paddler_model_source" } +paddler_openai_response_format_validator = { version = "5.0", path = "paddler_openai_response_format_validator" } +paddler_openai_translation = { version = "5.0", path = "paddler_openai_translation" } +paddler_request_registry = { version = "5.0", path = "paddler_request_registry" } +paddler_service_thread = { version = "5.0", path = "paddler_service_thread" } +paddler_state_database = { version = "5.0", path = "paddler_state_database" } +paddler_test_cluster_harness = { version = "5.0", path = "paddler_test_cluster_harness" } +paddler_tests = { version = "5.0", path = "paddler_tests" } +paddler_tool_call_validator = { version = "5.0", path = "paddler_tool_call_validator" } +paddler_typesafe_translation = { version = "5.0", path = "paddler_typesafe_translation" } [profile.release] lto = true diff --git a/Makefile b/Makefile index cd1a8c7e0..e892386aa 100644 --- a/Makefile +++ b/Makefile @@ -2,20 +2,26 @@ RUST_LOG ?= debug -PADDLER_CRATES := paddler_agent paddler_agent_status paddler_balancer paddler_bootstrap paddler_cache_dir paddler_cli paddler_client paddler_download_manager paddler_gui paddler_image_decoder paddler_inference_parameters paddler_messaging paddler_model_source paddler_request_registry paddler_state_database paddler_tool_call_validator +PADDLER_CRATES := paddler_agent paddler_agent_decision paddler_agent_embeddings paddler_agent_pointer_head paddler_agent_runner paddler_agent_runtime paddler_agent_status paddler_agent_text_generation paddler_balancer paddler_balancer_runner paddler_cache_dir paddler_cli paddler_client paddler_download_manager paddler_gui paddler_image_decoder paddler_inference_parameters paddler_messaging paddler_model_source paddler_openai_translation paddler_request_registry paddler_service_thread paddler_state_database paddler_tool_call_validator paddler_typesafe_translation PADDLER_SOURCES := $(shell find $(addsuffix /src,$(PADDLER_CRATES)) -name '*.rs') $(addsuffix /Cargo.toml,$(PADDLER_CRATES)) Cargo.toml Cargo.lock FRONTEND_SOURCES := $(shell find resources paddler_client_javascript/src -type f) paddler_client_javascript/package.json $(wildcard jarmuz/*.mjs) JS_CLIENT_SOURCES := $(shell find paddler_client_javascript/src -type f) paddler_client_javascript/package.json paddler_client_javascript/tsconfig.json +KEV_CHECKPOINT := jaredpalmer/kev-0.8b@788ddbdd65715bb03a56788c822f6c632c9a551d +KEV_CONVERTER_SOURCES := $(shell find paddler_kev_converter_python/paddler_kev_converter -name '*.py') +LLAMA_CPP_SOURCE_DIRECTORY = $(dir $(shell cargo metadata --format-version 1 | jq -r '.packages[] | select(.name == "llama-cpp-bindings-sys") | .manifest_path'))llama.cpp +RUN_WITH_TEST_CLUSTER_SOURCES := $(shell find paddler_tests/src paddler_test_cluster_harness/src -name '*.rs') paddler_tests/Cargo.toml paddler_test_cluster_harness/Cargo.toml LLVM_COV_THIRD_PARTY_SOURCES := /\.cargo/(registry|git)/|/\.rustup/toolchains/|^/rustc/|^/nix/store/|^$(CURDIR)/target/ TEST_DEVICE ?= cpu ifeq ($(TEST_DEVICE),cpu) PADDLER_TEST_BINARY := target/debug/paddler +RUN_WITH_TEST_CLUSTER_BINARY := target/debug/run_with_test_cluster TEST_DEVICE_FEATURE_SUFFIX := TEST_DEVICE_TARGET_DIR := else PADDLER_TEST_BINARY := target/$(TEST_DEVICE)/debug/paddler +RUN_WITH_TEST_CLUSTER_BINARY := target/$(TEST_DEVICE)/debug/run_with_test_cluster TEST_DEVICE_FEATURE_SUFFIX := ,$(TEST_DEVICE) TEST_DEVICE_TARGET_DIR := --target-dir target/$(TEST_DEVICE) endif @@ -25,6 +31,39 @@ PADDLER_BINARY_ENVIRONMENT := PADDLER_BINARY=$(CURDIR)/$(PADDLER_TEST_BINARY) esbuild-meta.json: $(FRONTEND_SOURCES) jarmuz-static.mjs tsconfig.json package.json node_modules ./jarmuz-static.mjs +fixtures/kev_0_8b_parity_reference.json: $(KEV_CONVERTER_SOURCES) paddler_kev_converter_python/.venv + poetry -C paddler_kev_converter_python run paddler-kev-converter parity-reference --checkpoint $(KEV_CHECKPOINT) --output $(CURDIR)/$@ + +fixtures/pointer_head_with_f16_tensors.gguf: $(KEV_CONVERTER_SOURCES) paddler_kev_converter_python/.venv + poetry -C paddler_kev_converter_python run paddler-kev-converter malformed-pointer-head --defect f16-tensors --hidden-size 64 --output $(CURDIR)/$@ + +fixtures/pointer_head_with_mismatched_projections.gguf: $(KEV_CONVERTER_SOURCES) paddler_kev_converter_python/.venv + poetry -C paddler_kev_converter_python run paddler-kev-converter malformed-pointer-head --defect mismatched-projections --hidden-size 64 --output $(CURDIR)/$@ + +fixtures/pointer_head_with_zero_temperature.gguf: $(KEV_CONVERTER_SOURCES) paddler_kev_converter_python/.venv + poetry -C paddler_kev_converter_python run paddler-kev-converter malformed-pointer-head --defect zero-temperature --hidden-size 64 --output $(CURDIR)/$@ + +fixtures/pointer_head_without_delimiters.gguf: $(KEV_CONVERTER_SOURCES) paddler_kev_converter_python/.venv + poetry -C paddler_kev_converter_python run paddler-kev-converter malformed-pointer-head --defect missing-delimiters --hidden-size 64 --output $(CURDIR)/$@ + +fixtures/pointer_head_without_temperature.gguf: $(KEV_CONVERTER_SOURCES) paddler_kev_converter_python/.venv + poetry -C paddler_kev_converter_python run paddler-kev-converter malformed-pointer-head --defect missing-temperature --hidden-size 64 --output $(CURDIR)/$@ + +fixtures/qwen3_5_0_8b_pointer_head_with_plain_text_delimiters.gguf: $(KEV_CONVERTER_SOURCES) paddler_kev_converter_python/.venv + poetry -C paddler_kev_converter_python run paddler-kev-converter malformed-pointer-head --defect plain-text-delimiters --hidden-size 1024 --output $(CURDIR)/$@ + +fixtures/qwen3_5_0_8b_synthetic_pointer_head.gguf: $(KEV_CONVERTER_SOURCES) paddler_kev_converter_python/.venv + poetry -C paddler_kev_converter_python run paddler-kev-converter synthetic-pointer-head --hidden-size 1024 --output $(CURDIR)/$@ + +fixtures/qwen3_5_0_8b_synthetic_pointer_head_with_wrong_hidden_size.gguf: $(KEV_CONVERTER_SOURCES) paddler_kev_converter_python/.venv + poetry -C paddler_kev_converter_python run paddler-kev-converter synthetic-pointer-head --hidden-size 512 --output $(CURDIR)/$@ + +fixtures/qwen3_5_tokenizer_reference.json: $(KEV_CONVERTER_SOURCES) paddler_kev_converter_python/.venv + poetry -C paddler_kev_converter_python run paddler-kev-converter tokenizer-reference --checkpoint $(KEV_CHECKPOINT) --output $(CURDIR)/$@ + +fixtures/typesafe_reference.json: $(KEV_CONVERTER_SOURCES) paddler_kev_converter_python/.venv + poetry -C paddler_kev_converter_python run paddler-kev-converter typesafe-reference --output $(CURDIR)/$@ + node_modules: package-lock.json npm ci touch node_modules @@ -36,18 +75,25 @@ paddler_client_javascript/dist: $(JS_CLIENT_SOURCES) node_modules npm --workspace @intentee/paddler-client run build touch $@ -paddler_client_python/.venv: paddler_client_python/poetry.lock - POETRY_VIRTUALENVS_IN_PROJECT=true poetry -C paddler_client_python install +paddler_kev_converter_python/.venv: paddler_kev_converter_python/poetry.lock + POETRY_VIRTUALENVS_IN_PROJECT=true poetry -C paddler_kev_converter_python install + touch $@ + +paddler_llama_cpp_converter_python/.venv: paddler_llama_cpp_converter_python/poetry.lock + POETRY_VIRTUALENVS_IN_PROJECT=true poetry -C paddler_llama_cpp_converter_python install touch $@ paddler_openai_client_python_test/.venv: paddler_openai_client_python_test/poetry.lock POETRY_VIRTUALENVS_IN_PROJECT=true poetry -C paddler_openai_client_python_test install touch $@ -paddler_test_cluster_python/.venv: paddler_test_cluster_python/poetry.lock - POETRY_VIRTUALENVS_IN_PROJECT=true poetry -C paddler_test_cluster_python install +paddler_typesafe_client_python_test/.venv: paddler_typesafe_client_python_test/poetry.lock + POETRY_VIRTUALENVS_IN_PROJECT=true poetry -C paddler_typesafe_client_python_test install touch $@ +$(RUN_WITH_TEST_CLUSTER_BINARY): $(PADDLER_SOURCES) $(RUN_WITH_TEST_CLUSTER_SOURCES) + cargo build -p paddler_tests --bin run_with_test_cluster --features tests_that_use_llms$(TEST_DEVICE_FEATURE_SUFFIX) $(TEST_DEVICE_TARGET_DIR) + target/cuda/debug/paddler: $(PADDLER_SOURCES) esbuild-meta.json cargo build -p paddler_cli --features cuda,web_admin_panel --target-dir target/cuda @@ -60,6 +106,12 @@ target/cuda/release/paddler_gui: $(PADDLER_SOURCES) esbuild-meta.json target/debug/paddler: $(PADDLER_SOURCES) cargo build -p paddler_cli +target/kev/backbone/config.json target/kev/pointer_head.gguf &: $(KEV_CONVERTER_SOURCES) paddler_kev_converter_python/.venv + poetry -C paddler_kev_converter_python run paddler-kev-converter convert --checkpoint $(KEV_CHECKPOINT) --backbone-directory $(CURDIR)/target/kev/backbone --pointer-head $(CURDIR)/target/kev/pointer_head.gguf + +target/kev/model.gguf: target/kev/backbone/config.json paddler_llama_cpp_converter_python/.venv + poetry -C paddler_llama_cpp_converter_python run python $(LLAMA_CPP_SOURCE_DIRECTORY)/convert_hf_to_gguf.py $(CURDIR)/target/kev/backbone --no-mtp --outtype bf16 --outfile $(CURDIR)/$@ + target/metal/debug/paddler: $(PADDLER_SOURCES) esbuild-meta.json cargo build -p paddler_cli --features metal,web_admin_panel --target-dir target/metal @@ -100,14 +152,11 @@ clippy: esbuild-meta.json fmt: node_modules ./jarmuz-fmt.mjs -.PHONY: lint.client.python -lint.client.python: paddler_client_python/.venv paddler_test_cluster_python/.venv - poetry -C paddler_client_python run ruff check - poetry -C paddler_client_python run ruff format --check - poetry -C paddler_client_python run mypy . - poetry -C paddler_test_cluster_python run ruff check - poetry -C paddler_test_cluster_python run ruff format --check - poetry -C paddler_test_cluster_python run mypy . +.PHONY: lint.kev_converter.python +lint.kev_converter.python: paddler_kev_converter_python/.venv + poetry -C paddler_kev_converter_python run ruff check + poetry -C paddler_kev_converter_python run ruff format --check + poetry -C paddler_kev_converter_python run mypy . .PHONY: lint.openai.python lint.openai.python: paddler_openai_client_python_test/.venv @@ -115,8 +164,14 @@ lint.openai.python: paddler_openai_client_python_test/.venv poetry -C paddler_openai_client_python_test run ruff format --check poetry -C paddler_openai_client_python_test run mypy . +.PHONY: lint.typesafe.python +lint.typesafe.python: paddler_typesafe_client_python_test/.venv + poetry -C paddler_typesafe_client_python_test run ruff check + poetry -C paddler_typesafe_client_python_test run ruff format --check + poetry -C paddler_typesafe_client_python_test run mypy . + .PHONY: test -test: test.client.js test.client.python test.unit test.integration +test: test.client.js test.unit test.integration .PHONY: test.client.js test.client.js: $(PADDLER_TEST_BINARY) node_modules @@ -130,23 +185,8 @@ test.client.js.coverage: $(PADDLER_TEST_BINARY) node_modules test.client.js.llm: $(PADDLER_TEST_BINARY) node_modules target/test-model-cards.stamp $(PADDLER_BINARY_ENVIRONMENT) npm --workspace @intentee/paddler-client run test:llm -.PHONY: test.client.python -test.client.python: $(PADDLER_TEST_BINARY) paddler_client_python/.venv paddler_test_cluster_python/.venv - $(PADDLER_BINARY_ENVIRONMENT) poetry -C paddler_test_cluster_python run pytest tests/unit tests/cluster - $(PADDLER_BINARY_ENVIRONMENT) poetry -C paddler_client_python run pytest tests/unit tests/cluster - -.PHONY: test.client.python.coverage -test.client.python.coverage: $(PADDLER_TEST_BINARY) paddler_client_python/.venv paddler_test_cluster_python/.venv - $(PADDLER_BINARY_ENVIRONMENT) poetry -C paddler_test_cluster_python run pytest --cov - $(PADDLER_BINARY_ENVIRONMENT) poetry -C paddler_client_python run pytest --cov - -.PHONY: test.client.python.llm -test.client.python.llm: $(PADDLER_TEST_BINARY) paddler_client_python/.venv paddler_test_cluster_python/.venv target/test-model-cards.stamp - $(PADDLER_BINARY_ENVIRONMENT) poetry -C paddler_test_cluster_python run pytest tests/llm - $(PADDLER_BINARY_ENVIRONMENT) poetry -C paddler_client_python run pytest tests/llm - .PHONY: test.coverage -test.coverage: esbuild-meta.json node_modules target/test-model-cards.stamp +test.coverage: esbuild-meta.json node_modules target/kev/model.gguf target/kev/pointer_head.gguf target/test-model-cards.stamp cargo llvm-cov clean --workspace cargo llvm-cov nextest --features tests_that_use_llms,web_admin_panel$(TEST_DEVICE_FEATURE_SUFFIX) --no-report --workspace cargo llvm-cov report --no-default-ignore-filename-regex --ignore-filename-regex '$(LLVM_COV_THIRD_PARTY_SOURCES)' --json --output-path target/llvm-cov.json @@ -155,9 +195,15 @@ test.coverage: esbuild-meta.json node_modules target/test-model-cards.stamp npx rust-coverage-check target/llvm-cov.json \ --workspace-root $(CURDIR) \ --gated paddler_agent=97 \ + --gated paddler_agent_decision=95.99 \ + --gated paddler_agent_embeddings=98.04 \ + --gated paddler_agent_pointer_head=100 \ + --gated paddler_agent_runner=100 \ + --gated paddler_agent_runtime=99 \ --gated paddler_agent_status=100 \ + --gated paddler_agent_text_generation=96.77 \ --gated paddler_balancer=99 \ - --gated paddler_bootstrap=100 \ + --gated paddler_balancer_runner=100 \ --gated paddler_cache_dir=100 \ --gated paddler_cli=99 \ --gated paddler_cli_tests=94 \ @@ -171,12 +217,15 @@ test.coverage: esbuild-meta.json node_modules target/test-model-cards.stamp --gated paddler_messaging=100 \ --gated paddler_model_source=99 \ --gated paddler_openai_response_format_validator=100 \ + --gated paddler_openai_translation=100 \ --gated paddler_opencode_tests=79 \ --gated paddler_request_registry=100 \ + --gated paddler_service_thread=100 \ --gated paddler_state_database=99 \ --gated paddler_test_cluster_harness=94 \ --gated paddler_tests=98 \ - --gated paddler_tool_call_validator=100 + --gated paddler_tool_call_validator=100 \ + --gated paddler_typesafe_translation=100 .PHONY: test.coverage-clean test.coverage-clean: @@ -185,16 +234,24 @@ test.coverage-clean: rm -f target/llvm-cov.json target/lcov.info .PHONY: test.integration -test.integration: target/test-model-cards.stamp +test.integration: target/kev/model.gguf target/kev/pointer_head.gguf target/test-model-cards.stamp cargo nextest run -p paddler_tests -p paddler_cli_tests --features tests_that_use_llms$(TEST_DEVICE_FEATURE_SUFFIX) $(TEST_DEVICE_TARGET_DIR) .PHONY: test.integration.opencode test.integration.opencode: target/test-model-cards.stamp cargo nextest run -p paddler_opencode_tests --features tests_that_use_llms,tests_that_use_opencode$(TEST_DEVICE_FEATURE_SUFFIX) $(TEST_DEVICE_TARGET_DIR) +.PHONY: test.kev_converter.python.llm +test.kev_converter.python.llm: paddler_kev_converter_python/.venv + PADDLER_KEV_CHECKPOINT=$(KEV_CHECKPOINT) poetry -C paddler_kev_converter_python run pytest --cov + .PHONY: test.openai.python.llm -test.openai.python.llm: $(PADDLER_TEST_BINARY) paddler_openai_client_python_test/.venv target/test-model-cards.stamp - $(PADDLER_BINARY_ENVIRONMENT) poetry -C paddler_openai_client_python_test run pytest +test.openai.python.llm: $(RUN_WITH_TEST_CLUSTER_BINARY) paddler_openai_client_python_test/.venv target/test-model-cards.stamp + $(RUN_WITH_TEST_CLUSTER_BINARY) qwen3-0-6b -- poetry -C paddler_openai_client_python_test run pytest + +.PHONY: test.typesafe.python.llm +test.typesafe.python.llm: $(RUN_WITH_TEST_CLUSTER_BINARY) paddler_typesafe_client_python_test/.venv target/kev/model.gguf target/kev/pointer_head.gguf + $(RUN_WITH_TEST_CLUSTER_BINARY) kev-0-8b -- poetry -C paddler_typesafe_client_python_test run pytest .PHONY: test.unit test.unit: esbuild-meta.json diff --git a/README.md b/README.md index a22a72a09..e128f4df9 100644 --- a/README.md +++ b/README.md @@ -42,14 +42,18 @@ You can obtain the binary by: Once you have made the binary available in your system, you can start using Paddler. The entire Paddler functionality is available through the `paddler` command (running `paddler --help` will list all available commands). -There are only two deployable components, the `balancer` (which distributes the incoming requests), and the `agent` (which generates tokens and embeddings through slots). +There are only two deployable components, the `balancer` (which distributes the incoming requests), and the `agent` (which generates tokens or embeddings through slots). -To start the balancer, run: +Each cluster serves one inference mode, chosen when the balancer starts: `text-generation`, `embeddings`, or `decision`. To start a balancer that generates text, run: ```sh -paddler balancer --inference-addr 127.0.0.1:8061 --management-addr 127.0.0.1:8060 --web-admin-panel-addr 127.0.0.1:8062 +paddler balancer text-generation --inference-addr 127.0.0.1:8061 --management-addr 127.0.0.1:8060 --compat-openai-addr 127.0.0.1:8063 --web-admin-panel-addr 127.0.0.1:8062 ``` -The `--web-admin-panel-addr` flag is optional, but it will allow you to view your setup in a web browser. +The `--web-admin-panel-addr` flag is optional, but it will allow you to view your setup in a web browser. The `--compat-openai-addr` flag is optional too; it serves the OpenAI-compatible API (`POST /v1/chat/completions` and `POST /v1/responses`), so OpenAI clients only need to change their base URL. + +To serve embeddings instead, start the balancer with `paddler balancer embeddings` and the same flags (without `--compat-openai-addr`). The state database remembers the mode, and a balancer refuses to start from a state database of another mode. + +`paddler balancer decision` serves [kev](https://github.com/jaredpalmer/kev) decision models: it reads a document and answers multiple-choice questions about it with calibrated probabilities, without generating text (`POST /api/v1/decide`). A decision model is a Qwen3.5 GGUF plus its pointer head; `make target/kev/model.gguf target/kev/pointer_head.gguf` converts a kev checkpoint into both files. Decision agents need at least two slots. Add `--compat-typesafe-addr` to serve the TypeSafe-compatible System One API (`POST /v1/systemone`) as well. And to start an agent with, for example, 4 slots, run: @@ -59,6 +63,40 @@ paddler agent --management-addr 127.0.0.1:8060 --slots 4 Read more about the [installation](https://paddler.intentee.com/docs/introduction/installation/) and [setting up a basic cluster](https://paddler.intentee.com/docs/starting-out/set-up-a-basic-llm-cluster/). +### Serving kev decision models + +Paddler serves [kev](https://github.com/jaredpalmer/kev) decision models behind TypeSafe's System One API, so TypeSafe clients only need to change their base URL. Convert Kev-0.8B into the model GGUF and its pointer head first: + +```sh +make target/kev/model.gguf target/kev/pointer_head.gguf +``` + +Start a decision balancer with the TypeSafe-compatible API, and an agent with at least two slots: + +```sh +paddler balancer decision --inference-addr 127.0.0.1:8061 --management-addr 127.0.0.1:8060 --compat-typesafe-addr 127.0.0.1:8009 --web-admin-panel-addr 127.0.0.1:8062 +paddler agent --management-addr 127.0.0.1:8060 --slots 2 +``` + +In the web admin panel, set the model to `agent:///absolute/path/to/target/kev/model.gguf` and the pointer head to `agent:///absolute/path/to/target/kev/pointer_head.gguf`, then ask questions with a TypeSafe client or `curl`: + +```sh +curl -s localhost:8009/v1/systemone -H 'content-type: application/json' -d '{ + "state": "Shoes arrived two weeks late and in the wrong size. Also I see two charges on my card.", + "model": "kev-latest", + "questions": { + "department": {"type": "choice", "instructions": "Which team should handle this?", + "criteria": {"returns": "Exchanges, refunds, wrong or damaged items", + "shipping": "Delivery status, delays, lost packages", + "billing": "Charges, invoices, payment problems"}}, + "escalate": {"type": "noul", "instructions": "Does this need urgent human attention?"}, + "frustration": {"type": "score", "instructions": "How frustrated is the customer?", + "criteria": ["Calm", "Frustrated", "Very angry"]} + }}' +``` + +Paddler's probabilities for Kev-0.8B stay within kev's documented bf16 deviation (0.03) of kev's fp32 evaluation path, with the same top answers. + ## Documentation and resources - Visit our [documentation page](https://paddler.intentee.com/docs/introduction/what-is-paddler/) to install Paddler and get started with it. diff --git a/fixtures/kev_0_8b_parity_reference.json b/fixtures/kev_0_8b_parity_reference.json new file mode 100644 index 000000000..446152457 --- /dev/null +++ b/fixtures/kev_0_8b_parity_reference.json @@ -0,0 +1,134 @@ +[ + { + "state": "Shoes arrived two weeks late and in the wrong size. Also I see two charges on my card.", + "questions": [ + { + "id": "department", + "instructions": "Which team should handle this?", + "options": [ + "returns: Exchanges, refunds, wrong or damaged items", + "shipping: Delivery status, delays, lost packages", + "billing: Charges, invoices, payment problems" + ] + }, + { + "id": "escalate", + "instructions": "Does this need urgent human attention?", + "options": [ + "no", + "yes" + ] + }, + { + "id": "frustration", + "instructions": "How frustrated is the customer?", + "options": [ + "Calm", + "Frustrated", + "Very angry" + ] + } + ], + "probabilities": [ + [ + 0.261169970035553, + 0.457897812128067, + 0.2809322476387024 + ], + [ + 0.445719450712204, + 0.5542805194854736 + ], + [ + 0.0512363575398922, + 0.669154942035675, + 0.27960875630378723 + ] + ] + }, + { + "state": "I was charged twice. Please help.", + "questions": [ + { + "id": "billing", + "instructions": "Is this about billing?", + "options": [ + "no", + "yes" + ] + }, + { + "id": "tone", + "instructions": "What is the tone?", + "options": [ + "calm", + "angry" + ] + } + ], + "probabilities": [ + [ + 0.10337097942829132, + 0.8966289758682251 + ], + [ + 0.42718881368637085, + 0.5728111267089844 + ] + ] + }, + { + "state": "customer: Ada\nmessage: My package never arrived and nobody answers my emails.", + "questions": [ + { + "id": "lost", + "instructions": "Is the package lost?", + "options": [ + "no", + "yes" + ] + }, + { + "id": "urgency", + "instructions": "How urgent is the follow-up?", + "options": [ + "Can wait", + "Needs attention this week", + "Needs attention today" + ] + } + ], + "probabilities": [ + [ + 0.06726915389299393, + 0.9327308535575867 + ], + [ + 0.16245301067829132, + 0.35291361808776855, + 0.48463335633277893 + ] + ] + }, + { + "state": "Premise: A man is playing a guitar on stage. Hypothesis: A person is performing music.", + "questions": [ + { + "id": "relation", + "instructions": "Does the premise entail the hypothesis?", + "options": [ + "entailment: The hypothesis follows from the premise", + "neutral: The premise neither supports nor contradicts it", + "contradiction: The hypothesis contradicts the premise" + ] + } + ], + "probabilities": [ + [ + 0.9177549481391907, + 0.0768074318766594, + 0.005437624175101519 + ] + ] + } +] diff --git a/fixtures/pointer_head_with_f16_tensors.gguf b/fixtures/pointer_head_with_f16_tensors.gguf new file mode 100644 index 0000000000000000000000000000000000000000..a30d1c95ce1eb0b39a91e3d93cedd46b86040e44 GIT binary patch literal 4864 zcma)92XNHZ(+2{!sR4`u8<(@MNhdu?PrAd#)Iex~gc=NlX0QQ+0mm2u`8%c)unh)6 z#|D=xx;vF8>CO~mAcSf{sG;{>L+`|VCgGd;XNE8H?Tlu3cK7$&r`4N%PhGoy-Q|;N z)vDF3dfrRm->O=Z4|+`U#N_nER4Orj)Yz1a-6iMqGDan&FQYKA&zsP?QOh`#eN=?per~h|?Nonul!>d-2p7EZf{)hB7nIk7n z``ew_F8cq)kvuW!Jwx4pFjP4++eQ34J}G%rO43JqFnP-8zj_ezG5Gl8>D0)S#L3mG zf(<_;ytn^Xntd?-`;$@cLEj~(j2W9zqbl6tL&|@{bsu~mg;#Yu{o_@2@87&iLjP9P z{!#UB*uU59@PXh1{{K*&H{IL%y4BdYv@J7R&u-VPr3QytTlyNc-srP!HMg1<-_6{d z-$N_xD0+_^z%s1GcEQt&T+NL^y|lWXcif+j7Q|zI1X)O);RDfUYfI59dL~;YjBrxa z9c_T|d*(Rl5xn?Jy`s&faa2cK3Wf{km1kr>l;B&U>@YN1J5*dHPnW+2fwuCZ#`FWv zJDQ|M5eww%aIU@&`qpZvD&;USoiP|NDMXNor#Qjd;4LaczHA-fIF6+|qOr#2Dd3H} zjw`iv6dehy#1O1G41qJ`{pzd8aGE8WIdWltizqHfeE8mySkE?UyeZ5$pF-??VIJeh zq14x|(V9E~;KXJzgfA!7)0N;5Q;pZtTae9_Q;;b4z&nB|{9E`1{l>drZ0Oyr)=<_d zwUIvN5;K6&Fpszz-k?jR3Qx4W0yMy5(F|s%ca(4xPGNe3`AjiLEBRAyE}baM#&)h7 zs|+wtr?Zsv(bwH0(S8WbdyuO9OwB6{!8)6>)itEf_aAx@n9d(E9W}G=<5<3MQi(yM z9VOx>UjS%IPZuW$HH}ZBiiM~43d<1m$C}x&0iwi#XgnM!M}r}(OX29Rv}_^DyMnnv zD*Q_T;NCR7g*EyEW<^Z zn*YS}RJwqjVzwDF&COih=nNz%dU6YsrL+}}fdDlVjgu_&Z}iBLQmljdGa`ZUcxIuY z%3?uurzyG2d0{&hFGR8yWwug-a~oAdQ#a0RWV3a)kQ2@_?VQ*Z?*yQu4CZ<>h#Ke? zTSuD81zPgKe8EP4gY|Rfi{aLVY-4c_@QW=Vg)+rZ(3v@DN^qoGvWb?4;IKo)k0{W5 zQF!4#CcQFc!*XGcFV|8Leo*|m1fm#vsYo?nZtZ9tgx@m!=^sMoQko+N*1}_zI@C~s zrkbuY!uqJK(B8Ti)l`Ser!PBKXcVFDU%9Ep)e?tR=*Jj_!EVCo{3~_(aDPF zC+UdTL0Kx~v!9zrs7cO5q8E1{(q_3B9_H&PsIpDGO%~FCNs-FD`Nl_u{h}J8BwC?E zg&eBPO=Ai^z&i}p_JqaslN$K1IcG07pv!`uTO*MER`p}QXZr+o^;ixvg+>%4EK3Gq7(@$WR zM3+mL+D~&SQT!c8YN4u6p?K_yC%9_ArgFQ;!Qz+3Gsb5w3XK(QDVUQG^UF+kFgOgy@a@r3~(!u9P z(!b{O;%s>^v6uQ(Y{rfOPeC#q)S`>Mv75!;Nzd(foeLZT#O18ROiK`|wqxpd>@gt` z4Z-`Wm+0*&WIZdt>LC;`Y9FV7?4C@RYv9 z;cm-L<|)r4vJAf?_Vry6bka~Uodk~Ev|tz*|W#}ib@6-y?SAJVS?qXeoWCi zc@F$adGEaHtui-tcX7U}mON7H+$e*CnlzPsZ?sGzCsR^5-TW3hM zq$y=!gyWv)7dX<9l>eGv&7Wac0!Perc@UXc8td!i4;QaUZu3;fZs$d;ma>c(kHSHi z=MJ%>WHf)3*^9?TT;LvRGM9;RiD15g;$v{4gOi|F3rp<}ii7;K_=jpB@tv(1+oyh* zJc)fNy?D396W2!a7z&82AS_&fBb&H{_mz9VNn$C; z0};$Yv88s|{>;<~8mK**q{PwZ*mCv{b3G>6V^s6F`aqX=l<6bg28m26oWYL9>bs7J zr=_LN--wRJ)c9ALHSPw{txb>i4Q+$ACT_jj_F8O!|0p-8bv;|XR#n4SUL@8)7vUq{ zNzyOYWSTR#ZD~Rne%@QHMM0cQ4V*~wN#F0LODXU z$N1n@+O&2bVJ3@3l39nP!hZe&@EXw)hDyn9Y-rVN`dq4^tW_)VS@NlD%~8aipq`QU zrHqFC$QsUE*G4mE%n;TvCDAM4NcJTet9}nwV6BFn=ZEcF&vk-e35fb)H0 z#@uIw>safmGpT-2Q{|c0Xjuzl;3m2!J%aQ2W|%rl@01c$Q+rr258Z@ywBM;9VJhF9 zegjfbvLr||_%GyXcoBIGga}`#2bFc6bEI9VAkXDDf>!M%*_{jG!uT67RrQN^2%XZ? zH=4QUX;3`hd``*|bo4aOPO!$I$QVPT*-TxOA>Gk+V9Isn%^yS@Qx~wz@?BV*=V!7h zjF3dePmNa}y1H4)Yoix>x3S_hWY`ndCg+9*%0RZlq8o zZ`$A~r*@Z&OY-+#hIqkJME4}ym6W)DHBPyHdN(IOQ_5=II_tG0HA2wxZUm0S(O&C?~ z&-4IA%5{QA&!v6tc}%jQ5G}*=V1@80a4Qq(RJt*I$L!Wb)83W1_&YUSq%EwqoC!QmKWEU9-VYmFwhCA_sryK8g~HAE=MbJ+%}m zORXE}DTy!qO#F|f9ob1*fp)t4Gv9NMg$?*0pdE3!`6o&N*aaixw%kegOJWH|(Myy- zd1=+&KP1o*8=_0#H}WC2o-_}hbc``vV}r~=NKq|>4$z))78m)N8LBYgxS+rWN`fI! zedMhKi>XQ}he^e+E@#9dbWbdzZc|&03*7y%9pYKKuCz+LlY56dkG?@};gGy5;%mbT z#zVIiuS#j;uQ*AaB*q90xWnpPngvD70Ocqa+~PhPW{P(;lx8T0gcDSE=87~1#EI#k z6c2NkYqvux-7~=sYa4Tv=?_fD@1%l!eYl(UJfP#hw;cA*hXF>iCLj4fKCpW#E$G2tPcF7|fU zWetU0#JWBk)x;B}jn_D;F*md%ANvke_`AV2s5}0Hr0}6A6pS$hyRr?PyraV=ON|V( zl=M7A(U@F>A3bGC$G; z7dXE_W|Gk+pi1~`#YnCPZjm3LOVWB_?>difq3(CN7aVEXB!$xp!O?Y(VdwR|h12Fm zpPQh`cY1VOIu?m09eu61N_PxyY?P$isO z9NinHS;xXmaVayK>}KER!MIpul|0QJtV|=>;_a4;#!hgztA?Y*aSr80t|q3SCR~Q) zS9J@vj0#bnF`J}T+CHg8#2VqA^hA0Q-bh#^u4V69=E+1v2{lkLd!`CwB~~%o&XSvz z)6D1%z0^kLc#@Or$*G}-h09=)beS0;4-l@Xy*=Z-K%PdW8ok1C+cvp@Ig$;t%vDR| zw#%UL2j4EDHeV)22xrA9-us9&27&FSp}O0Jz07{bA_4y!x(T&edW=S>k4PQ*$@J9N zpk9)0mp)v$71LXE`t;H%if4GHledX%rt4We4c{z0oOqYkSS)^0Z6{PupA^so}96z8(sG%#5 z$?*?!>9`Ut!PB2O;w!S}nk%p!s0jUzuG1Q2rLX9Iq(jwW=>W4|98Pa@q^N~_2Yyn^ z1Kt%?|Dx@=SD+^r4Sxq$$+>bD#^9|kF<>$?*U^~`=d$EbZi{Qa_$3e=b@(5s7mQ!1 z4qco{%UAastHBX1h6Boa0^N|yg>3c|tCts+}A$0sgVX1Doy4Tk&s53$P|*4WJqQzV{u>mNP{Fa zM>HzRP-!rx`aJLZeCvDO^}XMJeb-vo+Sl59|9*R~z0P&kKKm?MWWP|5kB?7i?EUKz z`M2Pc`CEFr?{N2b_SSLscirk0;O-g_=Gh^LO{~3f@nq{?F*0 zf$qBkynJ{3mE?aC_;|UwdAsi?)Bk4zU%$WLo5!;75BQ5@;@|Gfeiz^1f2;SCY5!jw z?mOK6Vi5ZehB4)SqVhlE-P~Qh-2O`qb_II;s|K?F4ZhudkB*C%^Dh1|qvYQX{}l57 zFVJpxFVC$30%K2LU7!u)TJDSzw!xlnZe4g0_2{*xf| zpGo|K_&-1ICyG|D4m)4%Y{=#64T^IGhL=^dqrTj2+y~qYfxAR^qcNFrJ&HuLVKjcK z3-{dH)6}^jta{ZO4Q_qhN^bM7kIb}D0sL4!1O>Ali0{T;aB3LB!29N;s(%3q(aR!t zOPug{O9b$X?jk`S#p&<^FS^jz0zbYAAwmV!teKZ9Sb{2siATde{!^fCrG&pf^U-ka z#k|xja@0)y4;YBAB)VIYaP{ClR^#q%h_pRGP1$_rqV*)A7@G`^Hi;W=ihI{%(s8Q>)34=cJhYg@n7++rW!dF<;Qf-oIp*a zPY}OdPr&EaAMDW2#CSakBDVJ^vtr~HcBdZ39bJKdkp!-9SwK4DG>P2AmoO>vGnUAv z5OOb{J#4j>V3Cj+7fM2v2j0j98 z?iuRDuJsGLs;;9)X1(Uv?Ry7O!~@(uU0{d(^vTR4OW|8uImU)d)71iroaxVk*}hi` zG5&TlSU5E@eb$-Gruqj^I$%%kq@Bn9LP>J^yfM7gsKuEkk?i_S12A{x0Gj6&!3RZm zc&T_5D>DS}Mc!^`YWxn;)zvV$V+wJZz=zpx@4?qxp58jw$?|O~<|WGa;;6kA9Ja{? z&vXfNKFB5grd=SuIs}__D>44JGZx7^QZZXK8sa&R@~QIEEAH0J3sGfqwQ&Mza*zWz z^Kx|kWr&Z#4x^UE zpuf2|Rg^X*ZI?S3jamh|&A^x{$hH8JCdn?$5db_rw$>Nh(aTZ^AS-&5s=I#yfx=?& zm!E`PkDfDa79&g$R}&Nb6`?OM0pE3)Q3Y!uI$Tr8Uhyo$(m$ui;-&*%SvzoaF5|bH z7+e>f&b03S&B>d83g;e5Ws?@IgJ^#-s#p)a%NBYhf#s)~_p@;PL@~O}QJa+U_uz&Z z7vbOm8=~D~NC)gXq5Fy#Z8nTY#THlGaZ;MxxSj%P+HyE=NEQ4x6r*@UEPMP1hwQXY zVD7iBAhin?(_lC9ja9q8_`WGk3yurJn^a2r6!5tINqTOCX}1g`0z>e)WSv_Ufcm{ z<--tW=*>nn%E6;aADM<3Tj5&s1X5{akFVxR(x{X<^i@eb-byyYUlVj_`3Whao_P*q zUN?h_VK_>cTM+}38B~qv(U^H#h@MP2!&`Bh8Ln2x(&^)f)n^CdW-i6-Ps%nsRo}|4 zbJ$G{f_a$QISUkbemK6$fyC+Y?50P}f#bY%QSa%_1G{=pqY`&9`W1)L-|mmDXrrRD(n znZo2JR@29aqhK)bA*|Y3!7kaTPwG?EK<~3A?J%f;`*Bg2_xv`v*0)!6rD%{DC;4gd z>2NfA8%|2!E6|UA=5$|c4}RbL31Y_flHG%sndbgbPG*k*-R*u9AG6bOgt?0D$rP@9 zdcuq_=Xu1p9V1N|7%y;y?uJuveYy;(bT+{^3Fbsca2#c)7;yA?Zd5037vmOo0p^|D zMK>HTU>~IXVb>gu$4n_hlJWQ$wf`Z6A=4tsY)J$9t#u;ij_<1~a&N_JQTL(Zm>~V4 zFr6G2xz1VNx`4X6M&TP?K0Poc8mFAFz#3i*)XvR7r(0_X|2bKD+wBp)Rj9(Le6wi0 z^ht~hTumyY>X`O^8!Bce^&y~g<2Yr~+Mh11^&H7|0XxGN)pYs_X&wW%Wbv11{ISqbiI5F(> zZYbBMcyxjn2KOZ6iNRscbH8wEd2}bcsoj(~re=V{-2@o^ww1}dyo>}&7%*S5n=x|4 znsPR7qw`x0snc^o++&Pv&%TN5i4{RiyKEzT8Yc@hBhVlTzNZw^t5h((iWGwrU);J#K>?onkm=*n`$zv%=fA zJD_~&F*NE^CACGvI7cfSUOx*%qZ?mY<&j)AK;#&6*dz&3XN>KUl25VNU=HnGuf^-% ze;*W%_u`$wT9orthN&MN@z^AL^5cLB@w_O25!+nY)EWtLlG)Fx_n1qQ&KAOZrw@=^ ze;Hid=93Bq7y78}BU3Ff1v_2Sm~VpA^M>wL3?N?spmS70RMMzTV98L)KVeEx58kycOgD$y3V^P zAwe|m94E#-Ql#vh2OKN&z<9?jloU7%CAZa3PpTMq3Vwh$xnVpZ{nus>#|z^T%TBl- zdzRNfT!u#t`*5%IcHnljK*Ey@Z2o9Tj(skK(Fh9q$#cna3 zzDDE5RQ9>=OP2k8gi4%otn_-N`m*6&#tE*)0^*(-Hu(k*3ZR~19Q#%JtH?lmeT z63=cM5Tl}B5V{x7q#}9pq|$UpmEB%F9Qa+yt83RH13q?O75a&hxgW=~jhl=kkCK?0 z@~7Cb<^(T7nMoh|c3VY5U1>eW|R49v5h51_$`q$B#s7ZKx(N^+y zXdisLY=~u#L;}cGTGW{0-|5DIRoYUNRiM%y5V^skv=qurbq0Aip>J3z?(*2ACx10a_;c_ z#~pTwR|ok2Jc;LLoMesLH?q%!GibD?B)U!9M75u60k-!NS@G18G)~Pol`?sU<`0*V z86jMDiHs68(vzTfI<~@#A7RYg_YU-#>N@mjp>0ari`5|GS3$- zAWYFdvpHWsGYhLl;rSkOxVqa6e(HOYi+Z`}wKxs(V%LCvNj`k_h+*5;hZC`EJE$Jn zhn8jUc+>nc+1LRJ>pZ4V$7S(QBkn*2cAjRhG2(9A^LLlMWh=JaBMdG zW|iKY2AfTaRHybjr(ymcus3ig!nNt_NwO1Ok8t3SMK|{PYLS+gMHp6F0VOTPu+*))rs*nJDQrNOkm%!o?&bwX5t6A3@Q=F^|*CylJR*<#^acD|<%Zkpe!dm4v zyfXU{`}yu3@FTg*+*u(sGEA7u*R&rjBqF$Low~RKqZve0JF0rEc64?4HkKQGXq0>{ zPp2t1dr7Urc5dFT46^RqRfUZpGvx_cNkR}q?PJBZpzTI{)jCa7FEgu5zh+3=Wp_7y(FS)!3> z-f|NBC1%rEzNK)vePD=z znKO;gZ-vRDp?I`G0%K=%LD{NmIC5+veQ~M*hYmi)BfXO_kK2Xi6Lld{_Y^EX!9s4_ zVcgDLLUoc&X^#DB36lfgADGaE zSIE4xr7&Uoc=}4S*vxv~9S}OJ4PlS^p<$aH8EDu+Mf}9b+0!dYi*yB)wiv_ajxcmk zc47oR*^>|zT{2%alg)peg4;$eGw&D1VUx;5unD&Yr~5Y0oWX^kG>Frgtwvi{s)N&d zSz2dmLLB!_r4Kg5GxF^n?84`5u=$%TO$iT!LTEwvD}kIDZK90++&_%4!A!I=C%8uO z2pFGeV6E1eqD6-owK&Shi_li5QH{r-qwfjR-@O{Ea+<**Etpjr6-O7RAll$Fi>aH{ z&)$1;8PrZcWwxDAXHI{cN%EFo!tq+`aE{?8m})tlwQP@}%CV~K#V{edZ(b4dhDXu6 z37bIOjNMm{zp?ES;+pY{!S zTzem;pRZ+8D?)HdfeNl_+KYMPrjXp3H^6h=PH=fv2gmQcLCIiQSY5D!>|C2hgMJ=F z?Y2(bJn)>^yn6@buZm#W9(W)(R)BDg2hj4s1XAvu2T>}9bW_28sQE2J1Mj@TLlaCf z`l2M+b1IsN{ymvWTN*&f2Yn*3;w*~yW`N~PBidi}33Lv88k;Stla@zQNo{g44M=#( zRCl&iWl3h?*GsLCC4CtUI~PFF15Gl=cQ&=ZU_uRTMsfd^H_W@cJ)FazbYRGH0ev`f z5fjBO5~J(8u%+0JQHD|GQ??6SiIRsp*(N4^Pb0WVN|Dr%0BV=5Nd#M_QNjEaSe(Bd zSVw(`{tdW!ks{^Sv1LoTHqtuso4J3?i!Ptsh~6bDsG&;@r)^0fd)`)r4O7fTIgb@k z(-;nOcGThalhRb(%mm*DP9S*(z9hmd72A5|!HFt_oN7;~o~6uLx>k)&NiPMPqb9`u z%sb3U@+6C%m|?m^2)z9&1rKg?z}_GF^lir*%$h8XP!@{x(OJCSoyFPMI*;kuVMwMr z_n=VwIuf$02D1wEh}@GZwx}(d`53c-O#k|ZS^R^K)=qbzTGs9mH1Q&g_AEeKW*Kbl zJq3}*Q;E*`IW$Z>8w6vM@sgY|NxnCYUTukF+S|>ks`f!_XsCveYY!pr-fJ{;mLk^s zwBg>Wl&U)ZYz$SDp|M90MU4_m zWt8awaxqDX&UX}}&Z>#Hy>}R2`(5KONs&y!LshcXq#t(X=3vK;7kK1%3`89?1Keg?^0r$f}sO5kTsF-VJ- zvDdy&CL^D3!e}CgR2c210zNxwN=GR6)#PAyNDDsx?ho2OK>d73hyp~D7TVM7t$j2ej@aktQN$ay^oSg za+sweNZTf7!urS2jJaDot7aR|sJd^(f*M=Sb@wI|nRS@S)zqVhEA;T&_>)X%Vm@v* zRD$FfRXp?~5w|(?f`x|*ksCjgR!SMsZLhb%b+ag3?KguMJ2kTo4Qr{>yP5EyH<@$c zgfv4o8BwEKuhH$^D09e_#~WU50Ly*E$z_Ylpm8e}$`3#0Y`d;O#!Y<4idc`Q9<4`V zse}hLKR=PKyu~FdakNUo(3_Z#&BsNII*`9hh1w0Y;Z`;QBOV3wWUUhEW;rRQCS?R? zywoCx^=+Y0xEb-pJNPA83_or6^0X}o@hz(3wf~AhPotsk*5k>#w! z+`Vj&a|(K56oggGC+=r9(|A2$TKlOQ-Pg<@BG&_nnf@jip1{M9+G6lLwjI4yG|A7B zYA_ag%kEay!V3<4Aip{bMT|??oA-UmH1Dx9{Dy7R_v9QpdeoJiy`|4g;hRl#e#Q`| zk-4Pv=;g6~O`2R>5C`4gj&cOxs`P4vJiQLCtUe}8y z^rm7vQ)C?uyViFiiG7QDvD%zv-_Bx&It$U=jhLgVOw4}wU~z3EYT0g}BEOg5doLeM zN$x?1gH9S>(@?!fYV6?>eDV}PoW&C zv$~CDDOvQc`yZIK?*a{oKHO;LpcB#iwYA{VlW;X#?XC8PYZ?Lq^taMJ>N}?Edr~>h>NY%O3=wiC6_( z)f}6rPL`uu9bfT$wjOCuXR%X!3w5@bL$^r0LC!%B+^La-o4S{>)AFW}6`{*Hcz7qS z_ait;tHhkZv^()ezStSDENG;4SuHC5bIdkO5NULVDwTIoDSVV#hY_U zYZDJbf)z44Vw8gWyG;ir^(mHv~gZ?RhBb&1aWpPoiSeRcX=STlPS=6ybUmQE{B>g8SyJiX*QmjPft{zCMzsf#VSw^Y24DJ#V#m_Gr$#tJS=w0v) zR@e>Ti;tefMY$SeqQ=Ae$r{8Ww+hbtrhsX1AI_>0B{C6isG)EjBfe+B^c@nUCh!WY zl(m?M9&jbKSH@-tlU+%P?I64L_7%*@$phKoa<JV}iNf8EY8{89k2w~mptdy4dX=_sxqyQ7fj+L6`6>xty5_voMY zkkQ{im$`LQjo!T`NUjyy;=ZP>lrQuQv+1b{s4aO8&-U7*ZbSr#Bqm)JPw Fe*p#w1v3Bu literal 0 HcmV?d00001 diff --git a/fixtures/pointer_head_with_zero_temperature.gguf b/fixtures/pointer_head_with_zero_temperature.gguf new file mode 100644 index 0000000000000000000000000000000000000000..6474efd2f2ca3131bb13d0ad5cef7352399112ae GIT binary patch literal 9024 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 aapdiK6Am_0SFvUq`@rCUGhSiinEwLWFoYlg literal 0 HcmV?d00001 diff --git a/fixtures/pointer_head_without_delimiters.gguf b/fixtures/pointer_head_without_delimiters.gguf new file mode 100644 index 0000000000000000000000000000000000000000..febe0f19433cb23ce4e778727b2b54c0d970083c GIT binary patch literal 8672 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 F{2%P+dNTk3 literal 0 HcmV?d00001 diff --git a/fixtures/qwen3_5_0_8b_pointer_head_with_plain_text_delimiters.gguf b/fixtures/qwen3_5_0_8b_pointer_head_with_plain_text_delimiters.gguf new file mode 100644 index 0000000000000000000000000000000000000000..ba27783fc4c17fb60d10f54268789a3004ccafef GIT binary patch literal 131840 zcma%i2T&DF(=IvZBmyEq6i~_WY!6Ar1R^LZVnjeu1QZMykt`rlKuMAj#6Xa6wnxN( zh=7U#!~_TiFee1l<^8|!ulwJs@7}swwY6tw_Sv2@=j`^=Pxs89@9xOY!^0ymvH!CP z|Lee`@bBYhp8%ho8~qJ7?%cG+FT`h4$gZ6}A`^WQ|LzY8^a}{_*|~m;&qi+qD06%JVHh}W!u%YMt=nfT>BP0+kfD$+jfNeZ5}ynR6=f6dtz+QQQ#E` zCc9rt(6Kvy)G^Q=Uq3rc1kNA_4aE9tW*Cs`%$U4~?0*q&G{A3 zcqgA26qvxE6K zw}@HSb_Z&{yOSHIa`9WG49Uo~fTz=1aE4VJyL#Psn6>mf+LfP$S1La6RHYsp3;6I! z`5x%(_yTfGO&~usnRtouV5#>Y1lmoa*Ro%-JnO2s>66~#FLyl%Uwj%i=S$(nL$>6b z^&5~_b{M-28!_e2Mm#%dB^7tlpoce`QXX|)dckKd^F&0A)OU!H&J{}FZFdnjeKsK) zSAN0prH9z<&kO$dJc# zY9a9VcJ{%kzbNtb3Z7?G*tZL0frqn~TlsYsJ$rSSd8_dOVyXH1n6^^|KJH4x7egGXJXe5@HCM71HrHa!-;4>n`M^^<0F1$T{9JYnJreSn z-aUUf<(9{B){#6mbG`>8>=dUeZG?N?-iV|jFP(n71Vtsq>DHCANiFXPuF)xmLkAa= z*&}B3yX#9BzTii@%~DXMXA=gb%8@IVb3kLZ5}N){hoCi8DAAt8#(rhUj=5>f?Oqqs zGH(H0c(@XW6}>^^oTQ~!;{*2i?2D*Eg|Twwb>_{cKTy$<4|5hv63jLwv!*p;?QCsm z{2l|5%0HmM>lu5udNDNbdV(!G{bBU08_BTb#Z6C+QpqqKTok}%zRb*oTMNga$muF$ zP%TCWyUn?A{y-On6e7cQC1#~O^pL$XG@nr>5*LlJZ1fXEluNKjUE&~r${9GlKphk% z4QT_a;&rY+vv@3-*#D5kQ*}wq-yw#u(Z{&pwGLyBKEuats_;l!mAt`B=4;RgM&a22 z+w<);l*CBU+2Q7N-O@;iGoD1YHyTo_^cWOf!3D94b~GhMnjUxTz_A5GpmA{wqRsr- zqa8}nCH#<@FHQCEz z-b-Cj2{;YAluB8-<(8yx&ki)&9RPNwJak-iEs0vvjDKkWnf=fX&hD-OxS5P?B`@L4 zN^#ie+r~=ls=@P-5ky&{v|;g5`G>Js~5ra6Cy`?R157vy&Whx)E#j_Oh~yLiDNeAlRx``SvF2K<5)C!27LS#N5P9L#t}7lLVOFkKUShP{*X zmt7v2f<>}sq~Kl@b^j`Whc)AfzKjX|+$)KvML#y4_36ci_}fqy#ZNydYm;N(PmY-W!^%d7(QytbV1W-HR`-d)(Q%*ClZx->;D6_a-@BaQK` z%)qzBR4S^S?UkQ+hMxM7eOn%Jo)tSn?Za#i!$eRSt5~eH{f>w8OL2xlF0_960*z&Y zq;gUR>iF-$(V!ZpM@gMMylj{w^pTeqhP{O(h41irMg%@yeiwQ-SmKd8g0NWL0^W3P z#qL^h+GJpldS-K&?oDm@vZH|RihjcWu}yB=a!i0ulFh+y?%t%WtP0=0IRkO2lFa?_ zSP~~zgFKo>#7TM_6E#|xkQ7T=*J}imRAvz82fkG6Q#Ra5T26QAEwwHV=OapArjp4< z0~k`X46^?AVd0W~@cOZWep!4Rvy^9&y>Z^?E}zBLeDNWpS6|`H&xZ75^+&kj_y|%G zav@S<9`0?ON%>`$(#wrj%!$11te^RFOwqrH;;(OVZ!IjQouQGeH@s!-g4w=#KUK|JMtf2<;ZK1lgWAJz(fATZiuvK* zkt|Fc9pgL-ilGjXJJ@vt)?{T~0j#*024kPMFy-f+$u21q=0j;W#*NRV%-XHgve%4y zKH|qv3(Ah{mt+%Nb~6Kt9q>+25oE2`;8PC=x-zU1-|g5=wkMr{I}T^?$N6%m(}`sY zv;MF>^@4P#_c4^t8Dy_79mC8&1FREQkUGUK#-W$uICIRGwl&Pb>(__i;-V-t|ENw{ z&W_1@FdEITd}7tcPqQJyQB1g1CgkZ%ydz~EV3o;CI=ots`|ZGOP>y|zH%41f zX|ozkeZ3N+q}|EagH~j7F&`e?>c!?YOOaGKz-jZHMKe!U!l36X$Z9(eUf!0ZPT7lg zJ$}tJ@lD2;n@%yGN5yC+&uUn-b{712v52Ve)+4bFSHZo6a-XyQ7$oJ3KTfHVFTI9D z$eROYLL14`E9N9E$&V>|(FFzd>KM3k2l|si@IEMw>7VOp#9a--e2V}${1>LIc#1L} zx!h0+5hVAwvND8$n-6%x(y4RE6-_LNg7ta9MP7YV$STo32y@u=WUVx8bFYJ0z1tRxY zn9gj8_{baW3((4TOVkV8nM?<<0;9k^11?b zE%~%9mLbMrsWAJ;4$@}3f`sgR0oNxP(ARoXNY%Ay(D$#z`<<7$SEZ!Lv>UO+VnmkI zX8S@^tuLmmEI}E*lTdwK1C3;>a0mY@=sO+F6)=8oeOFWnBOG4B?WB|3Z)3F>Y4#EK z&D{pJLp_jozW}>mJCLaNmGJB6B{0sKMV6>-vT~tRd@kdc^A8;X0t&L-{2y_Iw)OgKr^qY!GO9r26a4SpV~H10pS#O>vwS~@`1we zf*Dk}d=hE24rp}UXN2GXG;&)9^vL(^t}rL;9iwnNnd_7+kKi3brfdb7y*qAdED-4j?-2ek+)h`k<18zvQ-DkCfTX*OK>LX z?4L_`q^zjwP!emkZ=>m+HK*yB z=pyWPc!KX$zA^V3&#{m5d)U^ys=#+jgUZj@3H!E7(uWB}AZ|Mc!T2D=3+-hOU64S# z)oNr*uN8=VD&>5?xSyOAI7HVx+C}7!NYnhIJD_eoA1bq&^!Xtr5~SnXZ$0zV zd&aRz1RjOjLH!;M{50N7ij7XA--1(6p0pf{t1IA>?=f~@bqoOC41ARDp9UKWR;FM4lVI zv_~?5Y`&p4$Tx?N$j2HB~*!MR> zA;{)5vnrtjX6gQA@YlBhYev}_=BCr2gkeKiaek;L2uUvR3^x z>0h*sl*RVo+k0!+C05pCPwQi5r_f{^ujB`1X=AwMyBmgfNe`wn{&-hktmZRDUsI!*m>oAEo{ipno- zX_$`!-MViBJ;!^A`{|4f-TexP31=fssptjyUtt*8E`>=tZ=lw#2_m8->67E__~Xz6 zjCd=J<+g9|qNE|j86JlPi3q2Y!*QGKLTZp@P0QSuAy0HRSj128xp^nSA}pI5G);?m z#rx8gV|lPo&W{9q+&~ss^CMdaq$0wZZqKMg-#G_iTzDGA14n6YjRfi3vlbiL?b(hS zADOtg$LLa(gg?K$fTMqx;7yALh|#ZMB_@Bz-@Jlk`Flb7ZJrGI?#@e_gFDfIOkv)9 z6DB{CCo%OolQ^G0P)^Uv)l^Erl~j&KHGbNq4B}Gru~p#&=yqrk%Yv(zbWV{(r#ysn z?#{IC$8Yx3&joPLe-u~Qy<)-~FA&p{H6W%fN}o-yvYu;t0|ZXahUl(u(7x4`d~Xk+ z!a?HXWX4j`BUcACJr=NjC>mF&c`|(O+{xi7hQw06h^@GngImYXGlP!F*g2&b7RSs5 z&)bWkyTBHHUfRuhS*k&Mmui9MYDL;=ZADh@n@aDjNns`p46%-n9>e<2iZmxC8Y)Q- z`drw>(RnPw7|;642${^lId(R>W&X z&}JP5j#0HFb+$NKfbKUvi|p7h^zS)H&pAAxht#G}`I-zoQXfXAzvh9TZq<;vv5Kwl zoJSSXwlK$Cd1>4|4#C^u?B9Vk7(07E=I6Gsd3A?z;h8Du*0~SM1t*i!Gp@j9(;eXT zuoYr&^r6gNMOb#mh3r^$ithe-2xmWjiR-^VV%F~opuF6p%;P)0Xq&`GY%RW{!$C1} z(Z3wxrhP0+aAZp2|6xz~NWMM9SqPO1v!qhZ*Md z8}}U;9DFySt!a^-uBoIYYcCB+d%!fk>}f2KDZx+YdZ9$_Jes|n2WRh0Co==}se7Rn zHCy}(4{YdTUfdkvgugR@ADidVyW_=}E?!K`F9%~!l`Er0elhP#z2HLpBxqIaWb#8h zz*|O^1a z%OR-^!%JN!@$zs9XKk-3GZJ7%rfwWTfdLP4IJg;0&KMD;`&{mNm+PK$%16v(xml0am>Jg z9aW!w2;1A6;BdoTNWS$P%{I!Cx%+3sEw`M;R^Cz!Q&FHv5fp`0??ai&Q<$^2o~ai$ zM#FVt(DAJv%iVrL;mT&b^s*XDnB0bV>w}~?Qz=6y-=FDAn zvc>8f>^NP9Ljg}P;?FUNKV%IuvDd+HXdi4GOhCo@jfC&J2mQS7BjnkZ!tTim#Gyl< z&b3s5l=WVmYp2(eXPm3dyg9E}1D-1wJ!D9W-k!wR34atmQ6Tok#J>V>SgsxKF zO1?bEVGHh8fv}_i>MU@9C%+r%??xkHU-F3k)42}zPmjih4~2<|wF|5B`T^K~e+Pfn zZK?9;aY%EGWJ;d7kW-qA*k2nW$s?)&tH$&vJhdQgd%ck9H+3L#DmUS~Qws69X$aU= z0S}_j;*Qliq{zd7Xw_E3&$23zlc;4IzQ~jD_gCRpIz#Hr_fWp=J7~^O7=COn!_vb& zc<;+jnEf*aB4SKIYf3F!Ebh*lc!^MZ%`oVkmjV9X-Vg#m@zT*I=(JTN&M!8wXPG%< ze*I?{EaN3l-p?i6+7hO9#x~~0(of9ldP{u1Sc$Od)0t%MHxMeff#w8pI^vy$AY?=M zRL&N_GzZuU0T@Q^$x zzc>dXwvALz3S4id3R%b0@yOS7+`8f|*!y}BCD9qQQP!MpeZCbg zbK-GXkPfl%>}FTAucDqWX26}dS)9T|ImTw4IW@ob9KCP-VvcNLxnoOAV99m~a^7AZ zrd>;di{bY;TQ5%|f|7Sx;klyJw>J_NN%>N{TuHk0nk|`b*$JleIFt2if}u2+&}nsdfc=cyhEH+F(bp>Cv!FW|RK75sGC$DM6&LjupX zatD4N#m%ZendOx!ki6cO-8{F8Jr;M7m72AW-MujfH`92CuCpXQC)U#xBO%)It_gjX z>k#3~y9md49gK;wc(|nsHb-qk|0&bS&*~l_VV0(wtQ7B>kzI=z5DNS zJ_rRvNs$yS-{nOLa=zW>;Rs{TcsUYRsJ zckn3&_&r0dM|qs5s^K^{tqL0!EF&9NuZ5fGA935Z5{|O_eK3%mN!LD`i`Q=@BLByB zG-IaIOWDQbs&6(FYFSbfHC1w(zU97ovXEX?8DP%Nje+3RFVQBcAB~b`bDTe)!~!jZ zgy9Y>Q&%ILKONr=Q)tQcPV}D_7yjso8Ra)rIguNo!5!6gq|#~MD>O~VQ#4r8OTR`DY1cWw4X^g zNcACe$QO4^%fxlVi&)L_$;2gW35SO7z@@f%khWklx-P%Z#0g74n&l)&&zS?#B294X zhy}GD_W-f`d&ot@$=o=lXohc_CkYJIpWvF&Ojpwow7Z94p8W+l#k&^p;Q{M2n)17U}I|A#9Qfoi?oqQXCZ_`3oOGDKV~qwX;&V z4$;VLSIdRJB>~iL!xttks0%**=0czT2b>?Xn1tR+0<%stx`Mx$JB9xoy|iX8jI`v! z*|!z!qEcxpuB}ebj`p($hh>SaUlpCwF~Uv}@+QeXA+-DDB77RU6^`p?(Xz?M+1i7j z7<-e+U?`+a2lN!^l`|7u<@7HYUHK4J9eD{W7&RvNFZ<_fI>>G_BcgVW$$9)wTa)xWC!72 zbr0v+h|{+CY#hi>hwMUQJQY|Ey~AQuCgTtOy0U`mG)t4EyH%O|x=^;;uaUWrYpIFV z5mc2^qien5p`qmvvrX(Q1SE}dU*#?#e+E*?BIizK_7;C~YseqpFYRG0ly73Dg&E~n zmErnCyo3EK5`g#nZR|Vj0oycm>40e{_IceTA3S>TgagosY5^#;_=Mk8q{y{N`;fW3 zlyI(#QVF%YaK(Nhh+Y<;x2MR`i~c$&K*m7(?^~2itiizxf8h4wL!{?p2~O^ahce?g zuu(gPgaisQYOk)dYO!Ked)_4W*;p^8O8tT*T7^u4ttk5MyT@kw>EoT{CdAIIgmLar zq0MHSxltuPH1hC!oa!6RMxLHSLXvrjsls27%o8JHLn2f}T!qZOk_-{Kg4~^)Z1VE) z6HHj8i;nldVrk(ATvYK&{gF;W3;mq*Sd<6-UGa3h38K!u;{|F1I3UM zyMYNTya(xQI}B+(ZXuPAKX6%Ni)OhjmFGrYcKZE$Fq_Yb+4p>#EOnh`a zkSiGj3bS;{?<_HwHIHo7rVB9Pp(r zj`}2#HNxdLx`4O44c_bD25~VnNdCHt%2`ySgv@hv_@F>uONo($mQA>BB)!4SdJvwM zO5se8*>qZ{0Z#s!07^q!Xk}Uqq)x=MJhBQjwxFKbsAq(6CsJu0?;yuBaXpJp5&_ARMwr*d%P~i1COa5&3HBLCGH2Wt<9ypCaOK?)PLpoNS65bn&3#YE z-G7&b<}{Qvxy`nS$ip38PxvO^1ub4raR1j{XusaT-P@@|Q|?Pn@Z1Q@lMrIhuiQwS zrWTWstD!i@;v@8|2)15oz7O|L=;3dw?;=SQz)1o^aP!=2&O-HT_;&n0Xj{dhTHVB$ zycqg9aSti;ZHDRA?_g))5_r*^M8jT(kUV7ra$pM!ZEP@}`F;wo1|%b2l{n^Z+=V-4 zr$Vy-EC?1dq|6}`GI;JLtGQDHuf^_!!zY%K@ZU#BdGv%1w%v-D>Dl3@wp{k-ifhPv zU1IL9DV&)9Z1&V8ZHyZXz*EL^LGB5{{!2mly}+LYX=S5bUmT%YD#UDqJ*gfuLTmXg ztXq{I==Q1N#U(NXDmGBlsg>xgD~jn+IVkw79L=Bk!`jGxy!gw4?BNk18SQfP;H4e# zU0w~$*M4QvA%pQ7^d!+69zgB4N)TUq53@6OWBk*c)n& ze-mSvdd_C7Igo|klg!zh1%&8!D01`e-fFz4?@hkWKEP&YwxX-=EnHZi&h@(61Om@J z*!nvQm@7t6uxPZJGfk=il&>s91^Nj2Z^W_3`gUT#>YHe4sR8rt#i=N70&%SLf*jLp=9+sTwO4e$Um<^9lrS}HKhU?W^j>L;3W3X3?hEgPN=dhAChw_ z@xg`^{Q7e@+FMDI<=K3=n?Dmouf4!UXEN!EnSrz~*abCg4uXoYDZQuu9&UNs(2zG< zVAFvyymIvfWGQWi3<)9H_Q;z!$}OXQM^+(sPcS&C8sMyFWstn!BUIXF;P>2E#@JyU zEcA@W6K_K38j&%G{n>@52cyVcb{W~Nqy^ifE$ICmOZw|u8>qSehS~`|D?w#3CMDbl z)gT+-Z^%W%ztQ+wTAOZsyM|1`a(rnlM&dq*k*dRIS-0g*q$`$>n&{~=kChc^eBNqS z>A^~NjsJeI->!jYk7vRpS2!b9{F)uv(9Mi9CvjcAt7rB9D#pw|?6^8StxcZp>Mu5ILlu#uPStRF^H;(Run z=cY?8Pt>C{XoljYX@+EmurJ(E8-UX5k7gu$U*oZ=n{JnR{R{6juu)07)9dF3RUwdg(bfwP@j zyip?g$<8D{)QHVfyoY=4Na3=({v>tEFpPdWL~PHNf{UXdy_$TDIpb_{gOop^Ol>ptZa0Xh!%>Z6%xkiMV$(@2723N5*+mdvh zS%D*FX-wdhL)6_+i&XGzrP5idxLw;3{DSL2x8VhQ(qb33s2_!kx&CzZ6GCcEMMJ^f zJv23~5!F|9Ay3vhNGtU~fs}0I#6QB7XGU1Xl6#Eg&tDjCJPN)d*C%|K60{AfqxIcI zaL}#=Hwa3g^7U*+B-4>@YLp~%zh*TaFjA(R6m^;${0tTk@{p>*cqrefPcOcCz_w*+ zT03nF#QO9+h&h#pt0(F@XKykir_TtJ;_rsURxTWpH?*SeMJ0Cmqi*yUR-ngvZRlF1 zHYn*4C$-%iO#0gmqxlNt0w)(YZ4_r895TY7?0hCGK$A8NhhU+jGSv>R#XGwr;8(jA zju#BVa7aAn^*(@-O#;Ns)rbV9EaKJ{jdEJfRWPa@QTTJ~8t4>`!8J?A;i2mzT=hzn zrpIU!-&6sv%8xvDRl`Dz4nL0HTBP9AjWA~Lp&Sw9ISDJ3PQkON7Hhdr2SIRwIMuQ{ zLGx~ovUe+M@rl<89ECY#dYS=Q*5=4|b$rE>#qaRVw;b%!ae!fgJ~Hqwkc=PMKne#0 zsbE6}5l#%EuhpNSe3~)S*wu}rH7lspD{s8TtwNXd(|EY`Bivtf74#&PNgPilJa}8l z$(~utB$%|KT&~4sI z67p<1IdXq3!T#AOaYX|hAMKT18*1elSw~Tkjc?!7^nAr%upQ{QcV3}-n*?N*UyIp>~A2}EjL(`;bK;^;5v7D z9Un#rZowNFqpZBoJ$U@51RlJv1)(G!dWUy0(|XH*^K^p}QU4Z&_sY(4hWI|g?MJ+{ zVf-?TY!xTLb-J`5G6yTye83G=R=8%80JZE!IJ(K3@hy4)a}qCtPxWNd)_)2m9P{u( zcQ+h7CkiLVW9d7`Qxmm-2s+ij*zBYuD5=iS1d(YVZs|xodE7`z^E@(di5s=j+Cbz2 zU&3FH7@F6&4>W&Ou(2=r$;p=e^zJWV+H+2h&f!#`+!Yz*7k-Fgv-!!Eh?B&)R)Pv7 z6=P)5VQd!Z0@;bWR*9sETxK>eJ-=N6$2^+Z!y#rgXa+C&kWvNHH7iB{Rwq<&4A&+ zG4^t^8|i;Kh7Wp_pfFn$xy*zXTl$lgEv|*Rxg3(jnW+7ak%ArN0&qNkGKp(7rh0jG zxTm!YzYCUw&QE??nD2s5`aVO@u6XD@k;0DMuEme{)9DSLaf}I^MOn27_+m56nUxSu zdjspRtMdsq?+PdU+XBgUmtc~|Asiu}P}a=tz*0E}&>vuQi8BNrC&**+ufHth1M2^Pu z6VsyALjqgAj&Q4Jk` z64E2ULoIz}=`+Pt4FA&)58Z50$dR93>|0EK@x0 z0(VBuN!#yI_#o^<++}5mqx)nc5aNcZ*=i(VGC!S`ElH&x*wW7FvJ}f!(OKT2wB+Dh zkY3WmJrH0+wGOCIvn?{jOn5b=U1f}V56B^c1<|Y>5OrY zLop^l1D#Xvu)CLrkvR)D(YlozI9h4EuxyVrv8)Zm+kH>*=9f3PIINay*Cl}^Ioq&d zUoM862u8f>PZ2z8G9fC%DhCGgx@<2@GZ2$IUxTF@CW-TYp0e9x7*H zV8<(LPh@{Zj9h6CcY5P4gcYV12e^0P&$d-6Q=RPJETADcz~)=<13lM2!6tC0V!7Hu`z ziI&caC^U67;a63lofe7c{iO#EUkO8pSF@>g@k1#7X-xAX?&3D13Q*s69f48h_~zAs ze_nT4H;PIN>jNXth^?4<|WU@aF)9nk$o%*M+5FLO`n{> z)k=%D3(O!Nj^Bm4kI^_#avDuG53&vGTA9^9mNH{4_V{YSM^-9v5VL)*Pt@tmL;IcP zWZ^(PonWE#x$^6|!Gemb6Ws53humz+}s_+#J(u zjDGkt#=+}6^cSvS;?G}Uo|x#7xf>j*`_yeTp?)vN#$1Nv?wOCz+ZHl`_BQMqtsGeQ zoP}j4^GW_0Z>Z)?g5`^yi1yP=E8W+sR6^f~ajhV9=z$nDeI(3jeGm>`I{TTFeJR|$ zEi3T#HVtO}A1UmZDhOo?g`5;Aabh~bciR=GOmOjXREubXx=ZEImlB8Ow{?l&X;aYN zKY&Mh2XJTV7g!we0xY{C@WkJfu-~nmWnOK;Wxq~hSy~*lSiImWc1V&V>iHP=ayiS< zTuF5KEJ$aPH5uD_6Y4J&f_01$jkXD)h3oX`k-a=*-Mk!@H`N++a&oxp(-PTui7b@d z&;^YZdhDtP5mchyh%L=q%*K=y!Z$@7qWY5`4Qk`y<-1;tmEOfZNqxq8&vzl_mF2kY zj2QKTj^V!4BlbF0xmjET@`<1}Ggo zoviq%3F%MT;R3T4ujE|Ezg}1I5Pu2xfk*Ex_^nnA=qtD=|a zC9pUC40rvENR8iwU*CHLBQ$KOW#Gg*jm6z)YRp4-ht0$K!p|H_Q7v%kNW$MChuWaBcO?7z7YCo5lF4)6X z3AKZHeH1gSxrgpA9Ar;4Yf#TxUf>D2!1*($4=>M6fwr@1`YV7U?sJA(KLQiGF7vx`k;{wU-&F-ro;%&;LTF%rcCUT}%dNK7|%4bt3W)VyUaEvRsusOP-^R=4-lYY*jV)u#rwOx&6Bf7YYxpT$whpttOi(1ob7 zEfkji{tHKwTEW0@4t(`0Va7%#a*fk^;X+UYW*?NN@r$nE@B7KH&P0eTmD$98Jz5TT ze}4c~`GcVIx*gO5p1|`3O2lJd5H5M}mBa8P6ZfSO_&NU(UcR;)?1Xip^Jx<#%`9Qk z+6OpUy&QU^DG}|9(=dE*A#+}MIy~AJg;7OQaQK%AnbYuzT~1CSv!e(nr$xe?^P>2y zZ4g5y*5qh-$6y1@Bs1-JXp_1LJ)axKH183nQ(s@FifvqU+jWj*TIOK;GCO*iGqHB1 zZIF#0OkqW8^kK~V7A$hkgriUSNWr!1`1w@=ggebbGwW%j4Wfuf=u=!~rh~Jh`RJi{ zg)pMw&8&?$kDaEGr1NY(ZeKMWwC|gtT*5dEZExc47+i}=c5yI#xCr=nNdW(_AY?7h zMu{1=B-uos-iWehZI4EiIV#uS&CVh^wI&qi7yRWeoCsou0}%P1g_42~3+NY--%z=G zGSBMBbqpcD?CsYqmOgF zXmQ|fc7Dqxj5;tI7wstp{gBJR-5*b4G#)XdUsJe0y#_J0?v<5;E?2UOV{s&VhZ~lg2vd^*J$&wdf}@~Xhuba*14J!}gJS+q=y_l>eQ^I0 zD9>9&h3@a8@v`OU9pMEbn-(yk9{X7pW&!Dyj%UR8RKv)GrZzKG2&E%(8gKX7L$@w3 zO@0#2>!>`%pHtyJZi^H-1-3kwHX5yA(x|q9RDy@3C zi=MRBhM!d$WQ)2!SwCpPeRW>dm@ zu7piHH-+mz?>cKGCP8PJ_F{eC3{rH-oE97?WXc+D;;V!){9!9fWVms7Q%eyaO~j3! zdrrWQ`cAxSGMl`ZQbcF{&#?RTGmPr|3yceaDNBE_iK4l5MjlI) z1{`4eR6g>=B4OegxrVBbDL%DH#gecy+=L~M!RB@xS9rTF>E8SmOQg?Jg{%TN(H>6n ze-2_!=>a_UN0n~=ZGo{fr_pN)LWD_tgnX)3FulJMepi=~c4bo%9GS)({nie_`3q=e z7NwR$S|rRsAwkd@&HdDotLs1d^ISq2P?VxHkfP4BHlj?4%qm2Zib91XywCgTd^y*3 z&h-p?uf6X3{w)g3<|XtD>BXz7F2WxDv+Q>Z7aVKK$NMXO;Pb1)a9QC5Uj1~GX`5sX z;cGjYa|SB6lM6lY^X&wB!jQT>nLRnBQ0I_)mQIMRj={*^BBCl>l3q0Z~!agzQL~DJaTti zBKy113g#3`;8V4OpksOfDjgqSfk!t+M^B{7mxQv3Yo0Pw|9C>dtS(llXow&8XcYFm z&;iup30HDvzu||N^uNTJPTg8^H@U(Yx3!o4KyB31Eo`Y@zs;5WM|7v zc=vON4V2a-wMRuc-_tAfx0yu3Rxq5;Tb!KL7sj08lQ8VQfOqSMAgPN|qM7aSc&GaT z&N*GfDE)UAF73|;8|}&L``v!1AJ79;$4l5q&v0^jrZug)DolGdG=fb1U&Iwk26 zl!O&P^`|#XX{asesR@JC;Wn5#WKDz|4#T$NVc;L9M2-#CG0orF86sbWGqzY#sp@l# zrAZ*}^s*X=+xLb(D4UTB;EW<7icgf*s|ZV7=%R2K^^Ud~V3XW^x+0+U!7AjURA#jv%jl!(vRWdkZ06 z69~Jk5n7ka)7pr0D4D*DmAMr~6h{T=u^o$uRjwdiRkRS8n<;odD~oY`EX2H8@f&8RCDTaaAfS+-#ct!^E_c{!m>E&kT+a^zJ%uAIf*uB?$9lP z^^j#33jOb7@fj{486vaEW0Q@rCg&cCP7oq1&6KF^Vk5GY(_;4(3R3;p=|tk}C_9_) z07cf>c!PJ8eEIYh5*|ij_`Uax^CC$yeB223es(1vO~i4Q_*{^gdx$yt&kyB`#Nphv z3(N`0a3Wgy4l}#bpy^j4`0UAJ0w=$Q4uiLl_G2G-=$~h1e*c8#4|>o-V+wJ&UBOI9 zxCzOoXQ{rv0@)>*4iffW^hr?)V;r=JEmtsS?xic@#Z)UM@xw1jeRG+e$!X}7RY1!u zTd*U0DYNDBY4)tuWxV;j6x__?>x=4z>1^LP8v61Eb|edtEyuX5$%#DL9Tdha*mxS1 zOGJ799v6d}!9%WJP=pCr8u3HBG=DTA8jHNt&?lr6D?dL0-482a&2MqWS3`%wnk{(h z$2D-UkR;dR=QEu{;fqTt#Y&>#4%| zQWzrahA&4;qGH{L6Z_XR<6&VnoPw!#-Sm zx`GXoa3r;9(oDK}1-ji3vHLRjG4{j|%v4!U7OqUi+x7~yWQrVUcX-g0rH=UIzmt%l zEDa)Gg{hQB8?)`wUV5!z6>O=ki7AT(ksXFqByua$G5!d6LMur{ zS1UZ&tjKyi^RAG_Z7o3v6k&c`y#N4zze7a@`LMlG+R;l%|@=*zBWUdcyg+$1O z_vviF>&rOth9TLISPwZ7tC=h=UzRs)iykW#Y1E9z{9k4xypg5_PLVYO6{Vu+2(dFkV!?@eLR)pC(I*N zb#^$nw4N!Bv!sXpC5WY>GW{%c5E~z>feYtKKZN2`1y{g!aaTf*1+v&H!?$Bb!GVKo7 z!gD6(=NLQXgfJZ$3FRw&SA|{IBuG=39&Fm8NmM_l5O=v%=wX?OxjH+TjzS6gYF0j+ zPV%N^H3Yl1y3rMPxb&t=P@!NK%PJA-WP?8z~U8zmj0A^AX1VQ`m+^ea1vo z6Dr>RVs`p-y$K^N(sMooUs)c;fGhH#QL_m5x$BZ!e_uh#%`@z@Ro-~z%^^m5WjPIl z9^7!fkfpNMpzXCdZMyA1-Oe3?k#1d*J-rM^X9du?+2-`{UN1}tzKWmKG*B--m^3c( zpr$Wvs9#_QQ;`{3*JB=0lUjx@S1!SKUlEwPHG)+Baiz=3j-b!4 zd1S->&-iz2GI_zVYntC~r$v`Gk?@}_81UbKz5E(65)m$n4*o%~=4cQb@WO@8u*gDV zohVGVSj&XwYLIG1kjSl5r@xI1L8f#f`^;xPb7JK>`p=>b9((JP!3!^eW16sL#mm|E z6Mr$z-;}7|3kTLUP@c{d=fR$*?@_i@jR}2QXfH2+kOa#a!X@dMP~o+U3~hP=4oTTG zVZl0l1P2&q_g^f%a0;L3W#LL!XZDoH3`%nzu!r?c;p+?=Y|*%iF-9^>1IO)XI;+J~ z|760v+UieoI@96Tq2sV%_EHiTc96Wwc#WkQ>sil})vViP1Jr4JiWkzo>6C^*VmdPn z^_{-6?OA8pu_DgjyfFzSJeT7EwJYTK7d!fB|3P|TmL&|ooCzG%0TXSr@K0kJz5mD& zT^}FAy75wEY#!jqj?9_4^SkFS5Ksm zde6~A*A!?{yD|0mzJfu_Wcq0@w-4!D!TuSUa3H7!V=pDL6JP&gqnG5v`mF}UC~pU? zckc%iReiGc?l`XVh#@xj&+{HO)L}ZiiryMtNZb9kL+RE|X4El^&9u?RTk}`pt@D|L z3s=CSa9?)OtPaL!{SXr^5Jp~&{>MnXa%WQAWWhae4E9F2fa?uSQaE!F2hN4C9&+LNGs3%Zr)&)x~0fi<^ zBs9hVUhEMd(OYcb&r@R@)40NnH(Qb;n!WW^VK30kkIVgK$HV*g*~Es+*p}C8(On8{ zxI--$^?n$Wuy7tth0jMbSh*cP+HcvWN#-J+7sES75K z>#jOT;_KDO<2{pUPd2k}=06a2(kJI5f@$KnThxuZl85AiqRLg7{t`^vUOU3j_aPj~@S*-}46>z!pUB~!Gs^+q{*z^3JX+Jov zcLF{Q)uy2uCs24E$HEAYCiBM5z~iw2JmdU}y?ex)mF*csC4C#_du=stDEWXj%6E7V zY!vW-bR2Q@<1&{wHOUMnRSfS=Dd{8@%$AstMo?E>0+$LoXn|NNQ)B$h#Z^-jYUS(akdQv+~U}R-QPjFWdgBPl43)J^5DiXIkGKb0v>$v z4$83)zu&au%e&R#+E#y*p6`$0n>ERwdp>k1`2*ygTMLepA3*O$DY~G^1{7kQ$Xokh z+7b5mH5;k5&H(uKZF3Z)3#L9j4S^@uf&~pW2 z+W#Z0fCf>2a|kvzWniYH2ub}D3m+$cVXxWuqg_2=b-t@2FuN&P^Ybr z8JtzkE}CFPV;)W>YCbZ!qdknv(AF^jedD+&T)r^m$9ifZ8v+JaHtbZ9DfFoR4p0-! z1Z7WU`t#*1Y|o!SweOb0=mt%&?cWUPArxb-zGcYw$2hXzh`cb5rxk*dwB^)URx-k# zq02a?WsE54@6(_HK9jJcbtQ)6tKsy|DLDOh5*{=;feW~tzu_Wp{LFdW&n`0|FMQ3J zE@MJco>hXQA*V^Hm*Uvb7cl2xA-v14gMz7?2P;Mg79^-+u&o7MyU(9WmF?m5vka`> zB!%^NCNSM=zT<^pj)U_=l3J}8!yn{6s%q|oDQp>jiIRenT_rd#?Jm|&XHdm{6Dn=4 zAvU?OXmvG}DP`CV+Lp`=;7iaKbYJ40s~AxW5uK2tot5OB05V9 z-XBVU^snDBDBu=MDKfEle$k9oGWHm!mxl*`_29C3Us?FgR96PFeCvj@xG2TY*L`>@>^a=u`8b>NbB!#Yo*l{ z*!8NP>EAF1VEYO*U%ncRFQ(PYAJs?qx?SL%H1x8#_{Y8 zpDfUHhc(j=;6F_h+;TvK(b+A4>ohW9t|ZX3U>90vo6ip97}C~Dljy77LS~O(AM>jy z0UE@fKugVOu$(GNml;Z8?L7rzsBggWB6HF8&kwfshdg>cRDhaBA3Ckbk!+a5f~s>e z^-wp0w9;B^F;1cBevS|}TMZ)yxZIzXH)$7n%+5Tp8UKrNg}Kqm{JGvAnZ_OsGR|ck ztd(W))n8M_!elr3vNe%z>2joL3MZNMtAFCe`0wCe+yOpEPm!AE#>DDUD!ZUZns{Wm zP`4wtC{maT`414fr(FcY*cj}*qz$G~s$_0lEb~4@n{>Tjg&kJ}X!M{X?aR<7cTJXK zve`G-o@+s_mfwJkg}+ca{U|O`Sqb`QoaxUuoFC_oI9;1zOll|BB9o*`uF9)YGqpRc zO;tO*6Evi*w|ZcKs}pG4YyyGyJg6NA=lK?Qf-bG(e^l_Kkrj&A5-3Abs;{8n-zYrV zdJwj#MMC6WZBm$QgRfJrG7{%5Lf%+1HatCmCyx4(iTbMSkWOZuL_iW;udqTHjsf7F zG7Ya!@PQWd|MTyXasPFBBIr4b{Ls|Er`3zVyq!npZxw~d&$40k@eDHm(rh^ST8rN& zF&US=>q0THM!2doi;Wj+!1=Pv>yzxp(2T3q+&(&yp@(Ah+1c$-Eq)uyB5uR9=!bZ> zXfvh<`LWVRWPtZz5CV*3=ubHr>fkFuuVixUK7C<2)#?+(Jjo{KzJEcv`{%GqwGY0y zK7%*k1!zy+E#`~sX5@&6sFYR#p@I&?OxB%NE*t}5Cq-{cx3L}~mF&TmC)8H0g(nrY z4g$RjXj$=1-rIX^RL6T7$ork4-mZU{fFDLU*!PAJ>t9H6JqGaD!9`RPtT=wdNqoJt zisQ&0!-@^1z>nC@PTD0!kEA8yH#uR#J2n}uUWXA$El0A{(iHB6Be(wy;HBy{+> zCmKKD$5)=ed(i z^G_92Y34MZ@^zrQ^8-5&{tTY|ii0A-Cz!TMhT0xoO*4|qP;Z$aWGRj^UyfE{@$*V_ z6i$Kc&8k#i3F}uP*X-mdB0g z&6jg&zfKX1r#cauaD+D{^&J1`lqK}PYIDNN=|{CskHB=vJLYAJCQ*^O24^=MhGMBG zYSm;wm+k0^_9QyGn2@a z%yJj9Nw2@atmieb>`^6%O>ttwcW zOGZw@iz`oo%3NkQuN9-`CG>F8(KMX8?It^Mn;7(~xR4n<1JpGYfIC;qIRA?QDZDIB zmMO;I?HNsYxQF21kn5nXF3G%;t|zp_22=MpyM21I(DBoFSXz{9%}Nydp^=y>)J z4kS;&5%)QG`gkoK7ae83->Sl*kM2b2s2)W2iXq>vkn71r!1cRA^xaK=GBT%&xwTiF ztg5#r9bCTPkg6}%=

^_=jx!+MA5c8dpBh)$ut?TZ0~#dl?PGR8vWL-%FM}s3M{!s5d$==n z5(-=1!YtooWWj|rCQ#Uu=ACmRn#tvC_0v5hUz7!YR3iDbREpvYLfTfUv4hdsFumJ~ z8f|DmsRB=8al4zfRJ)D*Fmd|g-Z-R07~swbWt!(Aj1^`RX~)xPu+(D}IgtJv`kok3 z*Bj>4Sg!)cyR*<C?@l6NqV01LQojBi8*lpmCiqxwZB-E}75A4r>Sez}04p z{;6_V=uGPV(S^Dk5v23|B*{sRgBE!&je1$@;+D{AjBHII50_rSj1ow#y=fXl%YwlCTQ`)#j7ZA~+{+)qF&k! zFO^C3?;dqb3&?~`WmVw4I*Zw9x`=#S;zdmwG)Y&7GZ{I*8)fC^(YnJ9MD6x^92_X- z2g$h8&zx7jL>S;?oh{z1lqa7qw}VB8CYzMS-P0o{P;S_i9_zQm{NxklR}A+XlOJPA zv;zGy-5o9;i9^Fu3w%_fL1S8*!N8tHk@bh*->-XkES^USgKzLYvsL);*IH;XoQumM z^~rm!9E{AqN&($kZb`(R=1^xefa-#RD3;70EKGSZ4E9 zM60dWn8A~=m_Km>O*xx|&l){R-V`n3+ipM}jrXxtObVIJ)k~H=(u`c^K~np4Kf0Lv z!H`uUx<7X$k4~QDS;t9}oWJYog#*sKR%a{LRmF?%Ebx}4AMc^jD?+m!>ao1U5Dx8e zfhPkR)U0q7PX4QihNmWyKihT5zAJjP^W-<)`hpG!mflGZtA)eW82vJoX>>|C5)XYTKzA@MjnydV9tXK;oyQ0YBv!0|&&H+NX`hOLfKpyT_ zpxRnt7~L*~;w#tlW9y{Ie05uTdUg!y*(%0*2H1d8${G9V4hPX`Z5ke)#bqg@W>ayg z+iYF+DR@_Kf??mu(BNWU2oI4b7D8uX)SrjymFsX{cr0!`Rt6C8o4KgQojI0ohng#u zU|nJWD`j_JuP}?lOWwiLFAo^i%_XQ6^A)rowxYm_E*6%gLz(nCdiTbCw(8wU_^2#S ztykU!?*@7DikLBJOETGlhUK^=_$M@ZE07;Ggc*Kt1uV6k8D8}+YM5WoUm-IU8ttN) zo<;Z2#Lbd^HZg|a`b4-nca;6I@(dGQKZ~A7aV9_R9U<3S9@hJ;vZfu=<9ID++?dar zw@8QJ7LgC{GqPu?@_WcWis&@@cPopV5+$cl^%DbTU=>Yibs3JZWvtW52*LUUo zb!WMp-T?n4HY(>Z{Ria9?cU4yaJeEAVK9|!e=LtJm9E76W;`i(*@;3K!YIDz38Zi} zYmU|)Y>g}dg`css`9L&1Vm*nHVtJ~WuMF3}UV<8#DH!CpiT(D znne?Erb;UFX1t57dhW|{TRfo!Ey=XbRPxXA5RC3Apg|5X#J}kVE4Cn!H00hxF>mgE ztY1tHZM?+vhxEYX{S!%|&Lpz&QY*eH_yM|Gjx!1hoabs&A!Hk;faD2fCRSJx!|q>X z95O^8!FD35O&?^{(~DtpffiY=@c=fwn}#3#6^L}%2FM;%A%`5!p`O4l#w*Vorw2A- zq0t&5Bd`+A`e!=VPG?_EoOyyYG`Sc(4zvTT za)coJD<~P$h0`ydfhbOo^vPIGj+|Zrv-)Fk{}Tnug@SP71V8qb<2yFKN|@Z9j*xM8 z8&*B5XI}C>i2UPIycN$>=(p8(nHM<|aLEfv(D$83t}dNVGNv{%86T#g?1RJD`6`UP z*whLA6<>f6qu;22O<%d=hi`m?H{e3e_ zkNc6?9DCTc>jLWO93Y|nwp8bs8cHh2lbyqlaK^nZNHta_t9v6+^r#BVG`NB{H=pJ& zpHs)rpZgEQLJr}Jy>-kb^;77$Z#@}G800(c<(QC4qx?U+7LcUDtEgru%ywT*q&>4H zWB3*$D&xMN*|XLWYu7EH`*#fEo(w;HW*z{N#Z_=yx)#6t&JCFgffG3dvrE4&e^w+0n!B=k|O&;=tA6 z{%0ATc`NYSdNXRaV-JsiON7KeC&kZYoh#0gES6td;S+c8q_9@!SiHk zg;*iCKP17NA#FTD^svJAB&;^bz<=Gv7~SXy{JuuUFfowkwXK1XH5KgpbM@?KK{+Jc z)1_ojCA<6OE2gT7W7;QtW8ZuD(iizj_-Nh|_#P}qx^~~-|C(%uGmku`j)FVj)Fp8$ zR&NZNZZ0soB$RGH>q{~d%YprPj_s2RB?EW>HR(Dy8CJsP6-#n;*L6(a?ab+gR#*c5 zFv~@YJpGal1EoP|bmKalDq4&?1kb~I$J^Lmz6s7atHIDyGm>HDKo&@LuxDDNNo0#G ztv#v2nAaH4vs8n%|FjnmasJ_#p+3~>^9}f%mCd%NzQdi>7O3o30O!{2p#o`|RH;{& z>iIF4VDXQ+@A#OR>u*Avj!Dza5>`0f;|a_d_>M`HeZ0D$GxWxdB{;ZcAF^|UuzH~c zQMC{UA?sAko^p!4Ro)D`YeR78OBfXp{DWU6xX@mSHFQ&DC$oF?QVc#VOsHx#vpB~d zKWb*djF>r?QsF@_PMJvNiR{HiX%jv;rJf0j->H!h26W9bA01326V%& z9CGjsGH$7c2ygREXAJ8%C`lHF_*x7ymh{)BAsgsP~)2WNWiF zwx;#rS}d+v9jy>>&&Z z$P#0{gOvR6XZ|{I-mR_Sbl!ozD4O^Y%*rk4NY8OzSaG_YU7sG++q8s6@O4P&{20dU zc?NzK_{4h_z8a$;iagZPA&bwIvqrgkB&<0E%(81x`O_sF43vXA0@X}OObTyv(~0`% zSsb%RgUbZFCE+)a@~xqFO9a(e6g5j*C*kTrB9)xVjl2E z8Avkehwon#sh*t$G2FWcV+UgGb7Ygqu8lvKt+_jCRgXKl6gZuvbo%3X+G4VH?N(xX z)SO*HG)bhBIy}qcJPohRAXPXGb}oL6pUsU)bE7(%-(OEVo-32{XJtr4)h3pgn`3vk z+lMwr7Lz{VK+bb?8m}F8=4P^_v+GuzV4M!tu;Ncop=GW!{q=l=Nz^)o;>o9(gX2E5 zg5wwby&+32d;*EyoI1Gfs{=l}4@2sdR@^;07ZS!j$O>WZ+#}?RHuJq`faz4?uMo$$ zy>!InkX|g@76liU@rd;bNm6*Gh-vN7fVm2vag8nyZ)U3y2SF*KH+2DtvUJ6mc^m^S zGL$rzmgA?8&CrxI1zY}B;~V=%Jm4q|R!tvyiTj`8)C1@6vid`C8{_7I2rPudDlgzt z^$C!@c?NgB(1x~#e6}_tnkjYdgQc^A@xs1D_PR|3os^~pYYQES|Ai^kVdy5?_m=CA zuZ$+42ZY$=7vq^1AI906V`p)m`Z8*aZ4pkidb`I_Sn9_>;zo`ra0sG85k20CgzrW7`tGA`H^cV z+m*vS`x=M3)yIInD?m(y?qH;Fy1nD+BI042NhRd(v*R<|s1c_p&8c*utuI9>=`lj{ zHk7&k*p^RsJQGLIJbEhY#LoaCU%T6iggz7 z#@mMU9h<~xa?Gq+9|QXJkR)?|0+*pEi^nORlSz!p7x+Et1zUCL2_{5tCx0Y$>>GA8 z!tyaQvP{1cPyWcot(%q*JCAa#o3;`|g3$vUPUs0rMI*Aw)KY}W?*EUN<_DD|=?g$Ho=GEvgg)eQyj-RiYW)8M8^Fe!FQ zCU=U@!V~i>GU?6?df7-HL*yot38%Q8p}rRVdZGsXE5f1G-~(K$dyCthO=+pEGM%le zh3dklDE#6e{2B>`0kuPz^Q8fu1a(M5{so9}`@}36;DNS~DUtqb#ANv!@)o@BV@d)! z4yU0ZDY&u|HDtfxo4hP0(cBZ#2L6L1M-}P9?o+sPVG8t}8{i443)0E>6_3AFVZYQp zV)g~@B2D|{a8xJ+?|lqq`nC)_U{FmpHj+HI6P)-k6%OMyL}*Zu9%wNJpe{qQrWKX z7&!fRJ_*j)L`A9vNF9>^(@XW}i?3TyL@fvZ?zs(5k7*H)+-I1eq<{m}MPT?)ko>-% zi}JZW93CbU&tH}#Gt}?1zq=Q}LV5(e?!3cj*(6kRoI#BIx$njO#`YDKRCn5Z`e}YF z98>(m2!E=fg^s4s&+%KgGzv2fx1@;JJqP%2LJ7&p>}3sQf5LK3XRZDTbWyw}&D-w* zT|b)8>fC4awlyY`j~bvE6GaXu3lQmU1C%$6N1>y+cqHK+Q}oT0Y}#DK@yj3b+R9$C z3$}Wb$u5?3$hZ?`YLxMs3v=LCZ3)@&Iu`qUCNtZsHSxcX-Y|RL2fV^Ri09s%f-5fX zu-d{Ja>j*;{q!@ia^5TSJyV389~Z(tjy)6~uRynlH?bu)eEd-K1iS19*n-dk&^&RH z7UUR!&m09@A3vRV?CHe{r_D5=(3dc3kHMqVe#W>L?cC! zhOZuCRx}#mnbu_DHvJkNwOEg%v!6h(-Vc~&+s;aR&1ZL>>jd|y0&v|q1>Y>*isuzS z!M3U!^~(*SfKEFF-Eunol(OR(72FSldPlguS&*z-ycPmEciphH1R5m2V*@W>;tYOPk{PB;T9o@vdX1?Dk zKogiq?`w~J;K%`3SY{Yhd7-QYt2A&92;D zK$Sg<89jAX>Y->r<`qfP%Tjxw>TDL&d~U$BizS%lRs%^MHl*d|MAqHJoth_A!B6b~ zS{OIL4$QHj1KtIj=_)m%dEnwHMG51 z7f#2|LT8_=Y@b33&e#?VO$V(>vWhDT+x)=SA(OEEOAN+GDxmOD3t~4war>9M?9_11 zWAHr|!X1h*RiTtIT6h9l>r0rsMh%SmH!gRfdkYuVY$a0)Ows4_R3!n~wVw+SuTAm+;H{MdU+-DNT}4!pPnd zxWNApj=hmW;dp5b3GpH&S*fs1RfA?(2SCW*7_7N>62;NFoaNYz!xE2eqhTK{v#Cr$fOuU$~a4%+~DIry8)kBONLBL0Sr*z0YoR9e3B;@$$D=Ohv@Jk{_N(3D?(rYqnmR51 z8I@PKj+G~3!%5WDAdoaE*)Z>(xX>;y9Z=Nq=C=(mrlk%)Ai<)LUAjVm$|zlhtAe-f zuc!Fpm&Q~~5Ic#J8pUbf`b2P_$uYarwqkCj6dRi~&YM#E2)N8hY|gD{P?gC61VCV zRIVI^n!sULFt&)7Ss+8*%ntJtCBn!bnMtJCRe?l#B$M}UDpb&5n|(LuF@Lo;k!8|n z(X^Esm|pb~#DkqUzoK?Y1k?urHeU+DndXInKkt zCU>H^qJvqklZDH)Cen$U<=|<06zOk}puX%1dgZ)3CdS=`gA0yB?WEhR+Fj0%zwQB8 zwU|?z0|HQZ=OqsAz0HONY{lT}AlQ(&9+ga`QDeM<*AcUxo*GSMx{?CexDXwBTtt#6 z1&Y$^&+hXI7b-IWo#L3%p+WW<`@qHA^)x`;6||{6JrKKqsO=13xxrfe_Bo#P+_DGw zELw>+UI`?|z0OebS_*Q!o3PpJA*XBCfK^N{3>}&VyO%XVl#Dpt)}>Acudavd>CxP* zK^=6=UxOE?aQT+&o>X{46P%XnfM3zQOqqld&Hv23$CI?dWYto*AvBD^gWb%t)xVHw zQJ@JA>ag*0BzTJR@yYM0te>J8O}ujzRCT%z?PvD; z{Xi1(ZV1!{R?%?T2}C}0IZYfrL{v8h(*NE{(ZZSGR7XbZNT(V-~C35smEzt?ZuSWE|EDfE1fSbQ^n(AH@7|B;o@GSZULHHztvf zLh2Dx>QIIUyx0Zb>q|4X-sKh=F7N>PPT5(K;V+$SIN0v`gV|rF9Qy>WKhPRk4 zpRz#dtT|Pz9cI3FDA74}^3>N-0K}fwgIvQ@vcxL_bart}l)uRQ7i@&0KZ-!vL6XA1 z98fzy&dX``<8qv4yaC1e_%%<9PP|!+nGdqixl52lnYE(ShBmlv>p?zoJ%R@gJHTs` z7}ghf!km^cs!P1a`W6wu{9xV)d!FY~cNaXT*Td<#nh+}~M}AJaPhG3ZAmFMV z84ZZx{3kB-{NzbQ)yWbz-(8E{+%vl9lo3e3X@VWPHTXV$A#d*jJ2G?NAPnq`AR}$U zWM-%?6iGPoHwAIC6((=4JA9s*VrTJJn#zlAFY-*9ljzS%bf-L`ZJ_ z6h>7j2Tot~r|L<@aKK9g!eynPTy6=qEGb~G&B{T)#|O#Q+lM$NfIF@3n83=@H0Y=( z;`?MvlZHnTsI*-hyZkeVH6+5MA)uXH*2Anok#;j4q+*3RV?KKsL@p15<&RE*{BCa) zPrD5pkKVx;PX~0*GNxHgW}w)4h2!#8;`kdmders?8yn{g?!t=XE!8F=`kvG#V3_kl zB;dw(lSuItS7H}m4`)kkpsvjTd7qav4qT5oan31lKNpK}rczX4M>u`5>^|dU-ozf= zAkRyT6oBfrGDPmX4VQo3YVY_c6vi9A;;PHr7>Sl0xV>U8wx5W{wcl6a)kp)XdZ8L> z1rBl?nI&wc5TRbui|ur|ddBjvCAOtnk%6y|Fxy#>%;cvsOE^!)p0Xym^Mqr>$vuJ# zxkmQhc{icI(wn;J{005Jx0(Fet>`eV2`_c=ndCR65Rz2IOflYzOGS*?fJ19xTxAZe zUcHnyZSKVMC7;nagqwdUHVo%gMB!UoEGBLm#^LUbth@I#svB!hCu!{9KltcCRlXmh zJL2|o?BjKGo4PB?Ur4Gi<+Ol}56WP3yfFzj1@cd931+UXWnJI&!jX%HAVjt4hTWz- zWej8PE46|}@F;Ij&>@VTaS_*ytJ6)LTo&zvAzgH16ZyVvJKngRL^_uZ!2|n4^vCu} zrhdYH+_>@@IxH4~utgtn;`Mp-SI8)+Z&WA0tKd{TVycC3NB;@zD;FRgYl>h-rZy-aIZxXMTu8n`G6pCmqLiQnp);SNaf}4D3C$t< z-cH0o^*a!-&kr2tSrgA?*)(zHEp{(A5PsBnEI24#Trt$ohicsoiFSbm(KcQc@9)YOCGyVKj)QXr$W+z1g*IkO?CQ)v9`(> zeud^hF|VJg#&~%4d?U#@V?>!y8LDM1gbRa?;M~<2WK+#uY%Srl7jpOE`=@^(q3RbN z{3%Y|UPMD6Z#gO!aP^%#WkxPlVc<|d`de%xS9ab+n4=4`A8w#|nG5MR`(Sd(sDn;a z6eiQ_5k9Go<4Y-yAGPBHvuMo=W}m4!y{jw1y^k|!&$=@(a<3F(cPWxdN4T8FmP(kM zD$5q1O{BhmI&fn6WzY`lgy`3Pu>S3AtV<3B-eZKDxl_^QcNAF0`{HGPTQ+d!2dKGl zr#>d%frg%EVBKHN`^jZEBO;_371arZOe#Rd(VY+#;>2$9m8LWH9f8bvFIZ{z0~O~d z;*{V|aCdAXzK~pxBO{)4v6>W^9FisLaGqU=PkQ6CuP)m0IWa z!uAcbpd$wHd7?diGpxvbR?(tFtP2Fk^{7m_J`Vot2OWhd{AIPAC~g&?Z$HODrj*H^BQIOZaMrLexN#N9&maCh%M_s;r(tNBuaj&6_y#%08w3k;p;n`tdWrjq{EV zG{+P1pDEz5zKrlb){vgwE$F^a4ZpWcCdWNKgCWO|o7d+;i-zi8#vu#(h~rU-&+w#M z_)~FR@p9VzYY-jopJzqY??K)5jhNC_$(}zN26Egd+k6i`J}$&%uG5I-NknfsB+ z(9uJUSLN9GZw3uFcf-9G-oSL{F6QDAAvm^qI#}9TL1>>aJ-PfYb7#K<{dDUYE_HK4 zNtC9-XI5kA5ikDw9y4-t`!9I=T8t`<%*Bld7-Bn5f@r=L#=o;}puC?J8O)ncf4|M= zuRkn61{*0jIlKf7^G|$D>oh!T{0}_mDbiIu6FNa~2I;*Y4UEe~>Xo;K=InFBpz%xe zh;A+1DZ9tMI#Pr9~j9`GgJN;JPbJ6WI9|)-mPR&M?YTW7+xUY9RMp0CLK! zP+2G$)~$KL@unn*)&3yb!mTNmMR9Dzs3CRhD~1Up-em2F3aywdNc`vC!`FNE!-R-q zxUk-b*bU7m*CN-k7d4lH)`q!cuXhQSepy6xz2eV?P*{%f0+HFp3S#VR)EE?wDID|S>~a&9J$(l z2a7z*VZ>?^InnEd<^QcmF9i`&rmarY&E2U*gAiK#dD1yE4pMC&D_HnEnm=;dkw(QO z(B&uipeOA}XS5|UJM`y+yL>z4T?#y_l} zMgs*_LAz@U^E7-qel_K^mXO2j-k2EHQ}!LWdL*F$=P?+wnT2A)7a(d38SiJ}taWYy zywc9XmaJTycbS_l^^Jan z=4lZ+KYSw5y_3PVW=x`=Eo6!4J{cMqdmcJcJc;VV|J9SrY5xw=<#(eTpXEA zYn!*i4&_)@dF5)1`s~a3c}>ZDxmozGcLa{xBda&nl=p`lf_|;D87k%diD>Ny{sEmZ zEVvs&gQi!r%KiUi=)A+Rdc!zwkB}{Ur)XIv@3|j}1|^cBk~FNAN~PsjR<=S3Nkm4e zh%(-DKcZApDkLRIp;QtL4a)EQ=ek_}cwOf`&pG$~{eC{$#MncZK22Ok1s;o${L9Pe zQ4wkOobL(vZ_g(D{aAwjqp|dT%pLrq)Qp!Obn50MjhzH|&=iYh(%`Q5Ul4z#folp5;+3si@#{Acob@6Ns?_pHre^?YRc*D_2-BoN z?T6vOi#b^MArtn`Hm8wx2iUUXpE))}KJ)0Z8NDX-j{jyOH}hTUj!P#Qk|*z%!OHRq zWYp`~<+oz#s`Zh)C9f`G!zBS)*WHBl#8>>Nu0-~eX^ij0$;L?xGXgm zwHyl}%w!@trS}hgid$jn#|Zqybt{c8?!|@`Tp!Nr0kkd6Wfzau;ICT?Xs%u(C`UYk zBQc|JZrWK$9R3CC3zWd%oFH-G_GER662#G{4+o6ye^V%5o?1DqK$-^L#N@-6YJ|A5Cn| z$a5aeX+-$fH>~{Nhwt9Xlb>&1uvtyZ$j6lqX#CzbtL2g`|Me<{$r zn)1Zht(2z}kj#YyX3$Zg*YIm+AY(P-5o~@ojg)bj^q7ooOR;8UDy_6nh2?xi{(?lJ5MK`3ah7;XGmc7WW#hSZ`cO8$8b_aK!Bo^D zn@zW4vfUEuC@M@YR5U@zHyzA{XmAqbu^J}RNKe=XW<+ujlS9sc+&u$gzcU)t_auB@eZRb=3M;>0m?r&=J?wT7oX|D&}C2xmMR}0g>p*vB1 zfdOBE>t9A}sK$_&GSsF(lROZZPq!6CK*0Ap6b!tKjyJVvv|j=gn@G{)f1lw(367oW z&$$irZ<4|5MclbI0VVt6m=}Rp;HdIHW=DM*dZxd?dk$9MboT+Sy*Gjz{wkrJtu}dL z+khFDM%k!aN3pq0hYr6`rbZ#IjLvckSpO^!8{Fz>T>T`Bm92*z$<8>{OP)6BXwrrg zru2&3GpyTjmz|ii81mmJ!sN`WjP~|$F#B50F3mbb+}x_MWS;`{Eu4dEZyM9$-NuA( za|I6%#RIQ3hBbZ2b<&q5FgcqzUP!_aB+V5ej?Po)BMoPa+$cmszfsKEzL5myf5+^x zWALCmoQS{Ma3){A%Q4n5GE`#udZ=CW zmF=*Y}IiBrUX$|6h_s$tVaP5^&27LQBv zq2$kfj_D~*%8!`C;oEbu{gw#1(;3Z)h)jX%0qvrm(M1uPX_lg|iw{tzK56>tqdS#w z2!#7ZZ=g;5EK5gx(Ilvl={R5wtDCpeMcQgqx-^i^sH?@UYE3H0@uPEf195zo5WT+O zI($90j_co~Fh)DC!;>SpLZAHj&hg=0 zh1gzKe>Q)949LIVPn@@`rZGC1=%HE2m)LLu3^G+{*x~`$wn+|jh#EB?RHUnpz60Oa z+;_4f-n7Q0@3?3mD~)@Wo6Ao>DGh<^jYl-%~XrDMh4TfMQ8XS8WJS`&{u1- zfahTG{xmyb=m8rqC_@|kCej&pkuZ8NkNGup8*i_C0lW*FNp{&ac#`>Yv7R`KXz5n+?9N(=-vXzfwkkVJwTJey}iWM@w0%!0^>r8Tz>y3Fg*pgh|L=0Za zog0f*!Sd>6#-;rXe__Q8>gco@L^!snNmCkWxAcYlJsQ+b)`vDI8RNnEXP~3tP;*Em%$bZ9cK(!};+2j1gmU!4l+ZT438) z6s|HEf}^$C@F{T{c>_MI)aC^E5+4a$G&kULgOjA;q7bc{Wyja6cn!R~MvN73qyM($ zkS3KW{0AJjbK!>+*s61iu^e*7c~5Uc+be(akn7K2ehoMd^kYi75hcW`~)bCPY)N8_WFj*xJi_7}xL} zr+d$5b5G~OeU9^f^kN6=|85#iPxGtJnq)`k4v&D{$;-?dzA891s-W+ubWGhNMT7+% ziFl_fvAXBUX1i;Xe(fAssh*87PHLisnJe-k zWsVpb-B$p5|A~=xwkzo(uMW&zsDaJ?F7%7>4pe{P2i=`A%#%q1oV#6{Oua5ik6fx{ zngbO`q~sq?Yjn&B8lL=%;da#xtOUo@OgPB(_qxAXE9#Ws zK-zOQ>qRE9%xz;AILguC5g~kd^(7ephyo>{i}04GOD9SR5y)`EJr?=spy!T07MVPc z>{NEf;Y$4LcoE+(@h3gW?ac9THC*$40YugepvWd6dTr+`j4hLcDKkPabLa>?lr@CA zmUvL-?DKFhe+4ePw+7`boxoj3i%opA7;}T3WIB)TQ(+f;RK`o@W>mFk8br^S34=9Z^ogD>xm(~1hqT_HaBL(z zk=(|rDeVK_ebb<+vC0t^i$a(R=_tWdcr7?owRvYmH$ zi{<`-T%rm2q|0Sg$_Nm#c0{#VRHywcL`AECZ$=d(($fjg3!_lvlsHMy(ctgsS4DHx z4qgpEh1$$1z&XCXyrOCMpmVwc=zK526W^;CXZ!Ue^olT92ghD4xuswaM& zWyn}KAh~DB9e;2}vUvD94Yy#cs z62T@$8`DVVW5j5?6fGC=fJxgm>CwAIbeEsa_!5(ibS$=y+TVz#Rgcq^Y^<-8yok$i@YKjBZi(r>{R z>kMM76+)5|?TLDg7}>hs8WWQ_W<`cQE|NOQtGK1ebf%sKyG}i_`P2@abKIDW5554a zx3Ng)dcaQm)qtlEpWfopPhWRa-C#{fm~+@#;&3buG+B|-l>%gwqAaeuGZAWk+yh(v zN7Zi26tFN~mX3dEL7(<-=)vYWL-p8IA zaQW@>6Ce@P&a_$Hg_o9TSo5?NKXaYnu2(huedle-I{9keA!$*t>JsLym6<@Un~LDb z=>d2zdm6&8_>qNa%a|5}^JGc72YvK7j(N#r@ZW?s)_Kz`)L3zheXX8>^D;N1T)-$( zk}}5Xeo&?k_tn@XUdHrI!5Qq~_Ujq$J9#M~f8h9mGE{c8!BhW4so;%A7_GMx%jS6F zX8n_>_*aQ4emqUCBssC`gEQIR&eG%9h1)_3MH72g`b@R%p@D&IrZR;RlbD{4ZATQ-nVe?@Fey-H7~$x=C={TO;{0@bcaVfLL$V~Q&ksZnw+YI@Xx z%hzaj_SZD#u}&YG`bLP|&ScXx*9!3QR-hesL`k~O5%MFV-@4c$8vhO)N0$b5qP?;l znW!9k+BlvRCIq3cz6$d)f0*Cf>kZ>$lgX{@Km5jsZMe$0o1JKXf_%6s%(1lWXmf5N zwHp;AF(&1VW(=3rJ8~M9dT++fL+`M-%b$3LdJ)$}9A~k)71Z17;cmrCsEbyDPU%U+ ztZf}m`>H~nm+dCvuh!w7T{6~Pr#0x@kWS8t?gzoL}Ze zhVtPwzHt~A&$-PQzE5R%n?0%d;n}p?k?U|gai(`3YY>?$pCI3gK{pdMxRq1ie`+=%xN+5F#Ile?J?Lr#EEDwCh~|yJRg&=I&%JzdJ=wZ{LRpFK(p2-pn9z zkLKXCus)o((jRAWxsei$6Hpr(1)X>I(cwT5*w?7e9+>tPChSW<8Zpj%pKVAVj2Xk) zRh@W0kjp|xy@XpG)%?Ia$Jq-9&f~q@X<+f>83e4Z0kd<(xXP!2gsjN|-;Hy~#pPZw z=fpq8?A2E2xtRsh9$ol)WG%kSQ6;9f43!(S^cHkx2e8}ztvnTnquQQKuH{bO%V+SF$geH%R@_Rf-g zY}if;Ob^gs7L8!&R03oDc3>I&p1F1)m6i5CNf(dnK}f?A=2mDk?3K18TPJEmzI+)} znJcp@?d7<0%UyQ4a5zfs`UmIRT3O}U=?D?Av{hv$xI6Q4STPI(s^jRpYpTS8`2r$s zb6K+B4{j)E!0)G$h;~FL_9=4L-_gbF%o+`qkb#-jSRVUJ>>_gvg2_vF4jd*L% z6k?xNgnM%pDU;U%g)^n-%x!{1{*)ESa=q#CBex)B@>JSgAwY-n&4`qN3T2uCi61Wr zJzhnFCHn+4EE4h3wWZXq${vJ6Y?zA{I@or68d;e%3tKoQ_NyXWwrZXj-7e#gyp`*S zMU@{U=39}Dk}Cef)l*1=T_j!GUjbDQ8=%Wyitk}Pk8Y74h5No!$bT9v8rlof;KLlF z|Ct&wRSqC(TxWb@qyP&=4z%1;fGS-RC*na)^oo@lz2!paL~hsVv;HTT=(b}|oe<-; zNrM>Y1du&j+-SIBCp0B%Af~7gnLSs@Sn_I!k1-=QxiTbY@&Ri0&6>GzHGp&+&8UtP z+(4Y`-b3I41@e0M9|(qs!SSzLp8vfD|HeW?$PrnHQ7R+3vxY%L`v}T$T}8W1F>t_ZK1jJn0jp;~o85lF(T2m;>eKW{ePt*e z82-X_*)EWiyCl%=)Fj$haE*N*uH*Uz5fayRf**A_T&NhWvD`GwFU5AfIro_ z_y}aD`Jh@qckQcfVJ&s_2_4s=re_qn_xvoP+hRiZJ+|SzW_@hb*_H4spW%yh!XNJx z&i|lTjz^@eNQ}jFdLcf7Zu_Z=%D3z}_j(!dje^i(yB%nSw&Ax=&mjBWAJ*^37JB>G ziRw|4Y#9Hr7|yp`qb;tU_$^%)q}Ry7K9`@E(*Ft99ycRaGHTSW1W_zIiB-Gp&hGy5 zjj6o+h?x(PR6xv}Y~cB#kDM87N+VzrVhtT7U1&dY2=Du@hPf&su+J)y*|**ThB~kF zG*9XfyR;5;;!UM;KfOrsB}2Mn&JXt6=Ppp@F4+=8H7xsd4+VuC!1LZPn(EJ{?Y=`; z{I?96eS5&}VibOToen~C#YtPE8Poi%2^`P*;pn(JW}HpHUn7I zh{LUKHdr@sc`BW9H#mN`hYhd^C4&bv(EOJ@85Nhae$Xxmd*&@ME1lk3EGRf(guu0@NS%s&F`v)g>@_;^>;t)cX z&sN0$)FdEZ$QnL6NW($l?Kmf}lKtyAonY1+y6dP4WXrDNzqIecyNQ$ym$jkAN1J)- z{=NL>+Z%9Df!lQ?-eN2E4saRZ3yhZR1^oF|fVMIxVWl?bEU$}2kveWp_r(^XMBV9o z5fgR=x3_*h`hgV>`HH$(TJ%xLW7uD74Nbf=JezqIl)^f^v0)vN;h4I1AFODup8zqm zDu7#Swz8L}=%CB+5qjr7MH7())P21esqd?XH8vW=Lfw+7ZK%WpjSHFZ1MA2HDTXRp zOuz|SBe`r@0tVUuiTR?90=;%5*=(p8^^uKQ5JV)y(@5xr4W3~^G*dA+qIdD94kUz zi%lW>XSA}78-!`o;9A<8{F70BcLSH}He++Q7!eD1qALO&AThiQzr5F{g%&2{`|Lan z?VL)DjcQQ13+QN2GFijQkoyB4F*sy3r47f3ShYK~4iU%aep~T!#%}D)eTR)txO|xs zA3G){P}=N3tISMKt6PH{F=#E za7J$?9M9yQ-S?Q%_~~o#ih~LaRV-nro)w~Lmmf1yak*$X#}#Z|$ivw|1!}QJ9Y37U z0j(XJR8q$guS6|mAJll$dcPG!!0Q8AnLoj^xyM0{bEs{(ElFc0s8XYGhG5kTglHF> zC(((vs}{pdNCC`UwSS27)W4k?orM$VF6FWQgQIvJ#c#U5K6LQsnYz(K+adf z#rx@?p!bm3GbTpP4Tmu8Z(5magNC#xZ4V@K&jxy21@iczW$3cH3zlq%ry-Vl%$n!n zkmasHmMi@M^L_hS&sFDPuwXOZtn+3I&Lh0zg`kF&7;WKiz@QDssH9~w?0Jy{&wj5# zu|2^I%6pSAm+g#>el7dURFuBT?qpO72`RX24;}d)bk&3fWY54VD%x&}ubu5F=xfre zpG7#=f-A}?bIkuGDKJB}3HT{y9!Q{_}puU7!$>v5#=K{omvHNeLj zB{0*epWAJ2VtYjez;KTcigP*KYoGuTj^A)tY#ak)QqjyR2Ti$bQ)AF;4B+_Fvc)>| zdJf0oA8{ga4pQV(kP8!Py^--fKM5}^tHNh%sv%8$JG1i8U+feuU{)Efp{0=<@U)Tx z$K*LizITMc`S12rVss&?nZAwdv*a?6P`A}Hm>qeg$euISLf0^e}(nN2jDrv2YVy2v%K~_dCr~DVEL1b+# zjha`*>~GQn-%@>|;5dmSCH+T!21OvGMB>s5!;FHQFW#}3g)S}J?-RUf=j{;IjG0Ui zUwQ}83J&N|xE%jEo?$P)mizre7oJ1($!&%T`Y1%8Z%;*32Vtn#{Zperaw9!foj zyJ0D8*W=@`e$73`VP7a2o5OkRPTXgvD%9b+`K5TjVgXSpNrNdF(kOeR9#T3Ma-a1D zj1Dfw+8cKv*C7PtIJd`9&EM>V)f(uzNR0Ig&fqf975v?0EaznHf$x2?ME}AbSUl=N zf9K?5|4{`RCvuOu%9}KCG4oedpG?eh zBreL6$m;%&?DIZZFqt@r0bhdXzGLgbSGpL*xU<4D;tKOGs1+8j&Olp^J2bsy3_`y@ z1>v{a^iORh{&)5cdKA=BJ*7U5LFY}3RVL822Z6|IF5tyoJ;V&RdXs+hCv>Bu5bVBo z0uQe>fQBs!q)k|W>sHUEc0$c)w?_cB47n36f$O;6T^y&yE7Rh>45F*?7Vmf+XZ*hw z!lV{tdKCxRWXa{EWM4086kH>PR#~7icAqiGJ3<%VOJF<&0_aw&9ki}A1thMVBvTch zLh|Hhm=k!5J=M_8eBLt?fAk%MsW-3TuEqhl=~l?5MoM6ffguz;DM0^ql9*UEmxeZ8 zW=#DCF_Gs=e(4^dIde^MU)D{A6?)D-t^dTYI&4I5x(wop0YYDA%mYCSTRdl}%TL@a z&+gvQg|iHI6aP~jlVE2U_@AtRp_yrH-oJFD&xEOEy%&`vr-{goH*Dvt4tVuT8Me0t zV-UyT3Fxl^NUvm1^&7z4Kyl&}mP17nCJ1gK=$06oA=Tc`NV0Z~8jb6tu zb!GUxY96^#J`N=zViapVd13*sM8ejRex3b;FDbyiLzT}YrG+|B)NYP=C>8#^>c^_p zXsu)@2B{KN%!Y|kxToHXPA`28w-)_@xrHTYrm9Jq8Lq@}O*vaWbQA8W4YJ$B7J#g) zA06AH3m4o+*(bR#@zXqEmJvJ*UgR!ToDl{&LovEr@;6=!^Fq^k&N%e%EIsg0l%@$z zg!h)7JV%30_-AJcg+O_98_Qs{c?(zUM z=%{8AqW#E?t=;&a=qdEp`vlGQ4#fAD2tD_H5GtC^F}FhsT$`e_dJ%_wn+6tv6-5?XiLdpbha$vD2nOZML#xyJV%`3R~?-P44Pq+wrivB^- zdtaWd!6W{oC?m2@`YuknmJjRX&%)zKuHRQ@LX~6JK!r*u+KB4Xvu{^|yKD;G(Y_Vr z=A|Q#Kf-pmIg`(sG5A}^1?CH^rGd6P`IBP{=yd<5SXIN~$|eo!P@RU^-b-Nr9ec9f zNYQ$bImTMgwxEOel|ZRG3yNjRuzSllh}Hf9^TSrMB1Xovu-OTxw(7#-H8v56oDrM^C*o#<#1(h`@Mm+R2hU~VcdE0gnMb8+zMcACYzIYQ> zMq5(%m!Dv*kTm?*>W?2jJCeseBDl)E3*Ov1M6^m{P_oj9r1rSLpd`oo%V0s?A|K{1 zYN0JSh?8#yrOin=r%PZOG-Od+2?B~+^{nMkHbj=+e@O?Ix8-HY{l6VG10dkmHtaWmyQZR)jE1A>p{;G_?}c-kbG*_P=K>5FP`=*bz#Zjz#! zlAE!+r5}f;IYZ>E^{{5qY|!lAwfy;qF&{Zm8|9lpQTH)15o)n`<@oAL60=%(zD*dIqg3J6^ z(?p&YNtW;+{hwv&&56_p6ipyDFH;MtezQ*HjwG&D=Ij<6IGsF7qm{zGoLH zEkvM(cyj3uy!x)B`h21#9ooEu4&4eT{5r0Od(;HgD|drmb~Ns5zru=q76j9Svq_CX zKe5(KfxK}uGL)x6#y-x4uAP%OUZ@h}o{*;|dla}Cnl0U;Ax3|9-iH&I$83DAO3xoR zrqW9PFe5CI-K!Tt8VbTe^5a$3NIwDZBn`uc?;Y^l$_MXFQ$i^-5x76?C0vQx04G_Iz4%>>+wlf-FDyg#C$s31!Bb4I!Uy)VULYzi!OzXK76D)6L}CAKr~Fhr*r&lGX* zv2L35Z@x5HBr+G;Ur(WjRe-wbtj9f^H>vdIWr#XB2Vb{-MM*`5PE#;}d$xh(riU>t ziZuT`Z);#m5s#dG@)Es6GEjZRYDV11fJE*UqaXf?(+4k{Xyz?9NWZq8>Sj!0 zo}V8B`J^W}`g99xrq#>dSbGgzy328S@I7|2nIY5sbqLQWa^6f?4_vk*lLpkQlB#)| z8HciX{9thu?4~S5kD>`o<(>jGS}X=tKMUaW!yK3u=Z^cI%hQ)fPod?zzfAF<35FiM zjV8wX8K=^h_-=n8th}!YC?$nTEqjq`qLKx_ZP7BQXNNzjnL|xI-PIzA8&zyCCNIJfMxrIQNS@3*$xRf#P!n; zYw3dCuMPBI@Ct%c|1$GSlTm)vM*Q*mIH<--)BCv>cpFkoaY(|0ck1>|;ESXY=8F@t zoY4>2WD@bXehS}MJ;Q_1R*ci22>o$(fIa1@$Mw24(u)~~XiVmNJiTH*80y!;y5&Kz zxa$Ly@4w6(JT5~O-6cU#!kemn^5@u&s<1!Y8Eeek(JZP2o^SPMKP>pdgl}4e-!@b; zwZ^^ZVmHQ2%oQMaoY!Epdo}c4d;sH4nq*1FL`=D~4Ck#;r_*0=q&hoSu<<7Cu*~rs z8Ysm=P2qjK9q!E@JC{xFALs&~rZ}3sk;y(u@d+>0hrdelAP(hiZ<+8MtA%L zj7U4OMMrAkcfuS<9d=;92h1dg{)m!|0_kue@F`ZyFG3jGgc>RFp#FRnBb}lRqjA>& z>zA;9W%Ci{C(*x;l;}Rub%dV340mpIK=o6>_UUp1F-wl8z@2#pGjN`F8itK#!Iowj+FZ7k zo;Eb3`?-FPouv}3FjuFq5)!Qq9qOTfY)&;r`y@RzWlN4bEu)sHWH$H3VQc#hv`F> z^tE;%8SL-IzNuU3RF2EH>)j+e`-(7qz;R#N9SYdjXAi=k_4V-UsW~g4@QK+w=Feor zXwf?L7LF|?1Vs6vH9r{Pb4O@f z{SLC*e=A*jF$!!>u*_jw0sL)$9CqKT6E*Vt<*9@ z4&VH;fyMqwuy=_qx`woXXkR9+i!Wk7*`4BRJ>JSBEt>@mkBo@Pk_L!9)6ebP4#K7P z3)np)-2Q3rb{u-71;dsR%#}DDdO~?RIVd`TO6T6d{7Pk-s3l2TcX^S6@h;fCwU-nm zM6oOQpP~2mX}mo+nKX%c(~mxx81Q!@-D+9}D+6+1V3shdReXkwbGfi;Wh2Me8Gs#m zkKrJHF%8SzR9&;_BeVNY9ZF_4L9N&TQ(9?*&TX&W7SUwO7ZBM1JG5qg?-VeK{d*Dke@pGRGDMx`y3r) zb0q9YXW)A9ka~rW6GrfS<5Fs$E< z*Lk6beKAB`+5??S5AoUx+p)+?oOpS+Ld88f`a|pq+;lxki&iMJQPbqna%T>%*eOKa z432|U-gYW6<0M3s7GO;M2WFjZ6j8p+1KU_RqGeQ%9xw+Se|^CG)?(=AI1hhU#p0gI zMIiL66@%tx*P`VMzPsJAJ36F8FwSDj>ArUcWUMwMXmZ6d8z;B>a@^TaCaF7=@!Fe(!;|#M?aPyB-K24NE2~1fW{6N}Aurh5lL9*qPau9Rzo2OA zcCfpOFs>m>u6(U#Q}WhB+VA(!dn=Z4TU^8zyz7T*waM^Q{uL^pSAo7$jyT28lC2fC zWXuO9llSXean6cAu%k}^K22m439pYMha>X{&Q_dF6GX@Kl3_sfPVsB??4m`7X!EHiuhr&J~jDmfLnEz z!HFY;lnS=stk`L=#Wfv+)w#^X=t4GoP!R6x>yhqLvh>P!mZ?Z;!HF+B8KvyG)ay4l z!#LT?^mQl z-;9T>bM`zkTZceTj4zRm@P_${D@o0)<0SS)5-R8%X9h$x=uqw~c=zTNRJSsy+FAk` z)1CN*e}!;lwjddPJ(KhuoJOKQ_pwRQVPNo63-!yGqb$`owPA7E$vH^ly2nFo2QlkwAgH59O) zOZ=xU!%zE^=mqm{eBMD&s9|s(-a&D;zQbuJvfqLMjI=#n1zel@Yur+G`2U#J)Ao-eQ!8Xh&pb&%VY3hxgFWB!Uztd9N0e&IZ>K|Lejw0IHyoOKO^_rHRh zK^-9TFoVfUR>4sNdlIwpF8lAuZrWr^cmeWh=sa7Ic-z0m({@0n$8&xB>B#%YaUp`1 z?Z&wVF|a8r4lZ17$IZ%4Xk3>;FHV|*%E=2!okt`FX7yJyx0i!kr7CS6QHHnW>g?*o zF09YVCQ>!?$)3(m>@AZg4&s`O{o_73|B&Mv#h4{JFLBDfNE>zGEFk7 zbihHBNY^;R!GrSf)oU)Mi*eZ{X>~Glu?snib(MPwi zMh1f5a8L}U9#EwFh0o&0&wqHQuLx4ImE)Q9+R=l0+ol1n#y>y_kvUM9=FzF5nf5O4wGyXAQKs=3&{^ErAveFMx=LZ_zbbL_PUAV_n!v`)6MO5s?dY zw~Hpp(H+AJ<`%T}WGCBtWD?bRqyYiPZbGrOGAXP%g6;QDFzd?eK;fh#>2vfWnY_s~ zEqN{;{89-q^SmIvVVFDjYtW^AK7nJ_#K+T%^ax21^*jMmQC-Ap-BzWaJhGVp$xi&V zXai1Tj^PsR%Z$a%T@V@Xj%$RzvR6Jo#zkobOjfK4xw`WJ`AZF`mPD|~qA+ulxF}?O2vZdaj zev%>nn(_p;Rx6N!3#%|o@jrODA`7zK-eP5YN?^W4HmultgoGF-(evML2F2K9@P00E)E%pu*OS z#HxK~-ffF;S|l7kuW)n~IOkC^f&nCelT z)stZNjBlvF){&MaZzKh4-RaqR8sxfA4a^tzgG(9*$Ui?>W}f$3?3=^&BClNsX}53? z+`5r<-^TpX*GCd|Wzy2ug>6H{swOvlP>_LaEz459;HRi5Y9G@Ykv&C*yQ_JI5 zx9vxnS5~lWw-}MVY)T66n^CLA8L&o0gXk@mAPZPAy6#ml{}jZKA};5y_4F&e@erb8 zM1}d?ychBvwTRkp3DE!bmTg>g685RSz%?O@vCSZiFZ|>K-_{@xj(M&^ek8+jY8TLn z;^s8P{2rT8;)LFlLYc5&3)Ze963U8Z(Dg1dK!+y5>IYI_AD@KEP8Z3ZXZLVy<8-!9 zY%}x_Iqd9Hg@qE*7&bqV*j`paSCbOFF&=~78=c9#UE0)h>VEiHp$%UJmr%RuO5Eov zWEO<~LcM3_$?kRK*eb!0UsB?Jov1$Y2IkcV zvGsz2aDA6Cc~O+d^fOxYnfhypz8VJM7YOnbmeHqY&cm9AtF8CV+(sV-sFB+0Qt&an z$A2H^ynR|>-6C~lIC==k`dW2uSP_# zzrr@xcc}7m3Y1Uz$F8~?hNWKTAiC!;Z^OkaxMRo#B{YwKaeX`1jffK)6VBzmw}Cyh z{4OqY{m$PoUmaGth|s&Abik;91<7Y((ET%>h_TgNC*PL}uA2yJ4PG+J0jhN8`}Ooa z_nd3;z5<=9#Yv#nANFW(1;+Bnm^x==@}=C-`a{hIVjZtb*EHut@w~nNQ*_?(SiNr? zx3jWWwn#=plJT7Dri_#{MFSy{XlQ8BKt_Z_L^2W;Dx<=4uDd7=qY@1irSVN6m4-t7 z&hKCUJ+H@k&V60?=ktD3+ttCeO^V~1&5*`Pr$p#p6$P?$ksxk=wvt+`JqgZj+`Y-E zk7%tG2#!m0NJM@M8&VyIy?QMDDkw-pKTIa)Je6RdG9b1Z6MvRNP(RIhEK2VQvFFkfr=iSx~A^-L~faYu#Bbke0a%x~b; zD^uwCRwZh5E|GqB7sM@wru3n|2Q5*IA_jK_iLXjFSen{WRzr=Iy`n;cwmigzeHq|( zOq7n(iCV^-l%tkYFxGYb~Me)>|2~UrX7|7djb#JtY)W z)Pk+fo!oC)HEtS~rC~|h{~xw{998Fx36$}JT6xvp)5u_{pIkX{eSG&1>4xfVRxL@`_EaL$X2-du`gsBS)1W|N zA8a1y&H^!ypiuW9Q~YBQem$iR-Qu=bmnuVFty{)*4EK@?7Ccg=ya?+~`SWi@DAF^k zIi&A%1>@0n94+mh!^@Q$>BAq3=t-YWrhm~bs8NW4QROpm#7KlyAR1)hKULcGTnYPb zvg}QJN3=IjAu;w&xNk!;S^4oGEPg9U*DRNy8_Lva%vS|)=H=1|mtM>fl%bk6i6k4N z=@hRl{HFDpl^H6*rhA=yyC!a~6cXY0r%K`m6$IZMlZaAd3AQ|yqQ*hHFxZ#d3 znXqjtfvYY}D0x+aHk(YN{wm$<-Z$LfyJrf{9h^Yl&q$)14YpEE5jDoKUkwa}oZ-Mv zOImOC26U6=Q1;43I5=}R4Yu_FW+~U3&^ZKJ9bM3r`U5A8mf{{eQK}Y;h8SsZt_=(m&HEsS7>KfHm2#7%U9-yKw(r%$@N`4B#m%jj&AfXmjBWc&MPOzO!< zJoi(a&MmMfKI>{Z-al(|S0xO(i$1ZhUSu+C^(?v~VHrKX#)66(Wq@6|92@MXNJ~yw z(Az~;^wNG&+P*m(U*#)Np6U*a*kVAsg=DCxiw#xXu7|>E?^(Aof4cI|9k_N#fUeEu z{7YxQFg3A4)LdsZU-k#*NzaNSrSdbV+sUKM?+8DF=a#^%y~o(QK7+XayC3Ma%q2Ai zU5qr>t@r8npgXcBvhCf|aGFFK?q4TJwuh>aoyk+kHq)K#DK96y*>n^QoeFVc);#E5 zsm=!3JZD{ za}L{REf0&oo~2e-i`fNz@zgbTD*2I_j-MDEn6qy#ktb^y^(QaUy*!Owzrvq9eWK1k z9k>7vZjK?}x<}ZHGg)@E@;bMHH<_6hmc+_Nc+-Fa3$kU}78o=Bjh7~WL&=71 zu&nz!W1?`E@#pw@&l&`nwoo&2WUnr*b1@|B&jH-EzX7ipXTz$AiYT#fEv!{{C5^7R zm^-$bzUr;r z1~{wYK@)9i@ah3abk}xc%v`RbmQ5}+%Pin6UT8;GJYEN;rf)FgR~ZMHDQDuIrl9m> zb7<^4jiw_DaD2~8e9>|ZlXvG}y3se-y3CvXyJiUFTF3E*Cm-IgoyEu`Zeh>Fse?>h z5`C*<&vs1}K%+B9(QLs>44$G)lT&0c+RB71UL{UGBo*P)+hw)SL!wdgZ#$D{CrHza z#6T-p2Bvdf&mBozAGiJ(G7N(P`6;-|RTp&g4?s}47PXZvLxT)EESbLu%(hV~PV^h;U-gcXR=#g|0@?M2BU zuVHYq{tuqUt%AcA)0tMiBK{J8A#`(A1ZBw_u&@(nn-3>)ovIYPKWs&`3zm?S(O__p z6{lK@pEG>rP$WXvaHCiY-W`2~DvRr(z1kAj#ZM;Y6=8H|(_GS5@Ez3_2$0jP5S`R* zKvIt`g5NcLaDSsH&Cu?Kyt)72i=;Z!sbS3uD}KgjTNJtc*$J9|C>6Wwjx)aY2Y4E< zooL?oF6PN^0l4?;H1X>`KuRl<80SN#WLRJhjSJZUbvu(#;6fRHo}>^}-Z+``reA{z zgE?q-YYijB=n&ug>NG1VhDfiy2hw+9nV|^ImEF1xO$oOr@p{Nq#`od6%u!r;Rv+$> zjpW^@02*pv$P?OP$}SQwMHiuJF#gcW&QARb>xI68Sy3EnuinWva`Q-XW;w3<)(bh6 zfs7`X6CEmF0GA@lncK7Uzzsg&*0c%aZmJEj#+7vW_hamw+eb-B0ahMec3)MkYzN$jfPUz{S3GkPz=(m!hm<10aT%nt&oq6s8UU6u4Lb71%{ z;_+U{H<(yFnI=b@l7E?^G+7}SyY`)jf*Y@C#8pq2u%QtB9gf3$VoI`a+R&C-X)^Vd z4{LizkIjE62yOa&ocU9UZtD35?+rz1OKKEUhF!#+m!xTRXflS4J_4&OA(E6Z$P9QZ z(-DOmu=`09{#cp9_MMr>N;VFFPSRzV*{(>&&F|1RtIJSe;6BK{C`7T`e*9t>izyHF zDb@eWrU=}_EOk}(e7F?Od`#dqRmtCv;6 zy>YHTys!iRd>v-^il-rUsWes9%EBqZA0cqen2d)Xqn3a4=_jvvnm+X+1{A1Mq0S>@ zzKI_F@$Wh)JDeb{0RSPvYPdgOE-g)#pyD&0!1rC1Kwlh&-g!r@uS^gib~1a3wWSK> zL{&_A7C}R|zu^AlA)ND3q?gX>W4y%~6098v6-(`CNz-ev{*=yc_Hd&=;!?mv;UIJN z#4R|ua}ctnS7VO)M7m?WBPF3@7&&taJ{9+*UQ?P;QQux|=ET_=!0VkNNuT9gBF2hqErI}S-rRr<4Srtkh-SV=>Fi7!;+G&oH0!r9>$mTtr}hTn3WLqmCB>TL1@iekpNB9l zL7S0^oDGM%3-QOGJ3aRB3QF>>;@_w9;DzjSJmKAq=VG{AZo+yp>=sD!F1un`pbn*W zx3S+^7tNR!_LKDm*5&}qXpkIc(uN+KE$E1eBV{;Vmjh|D&NJN>YeBtY2b+{O6TfUo zgNK?9gzr{rv!_`BCp;J8+*A^*^Cnev5szVtLi5;Xe>>RpPqDP{k}m2jEX3ENMGSLJ z0q=^5)9eIA@-|(Hl+?R})XqwT31>je>NS2ikw&g7cH8uL{i&XzwHM@;j`5zmv|~#P zmvh}w0qx3%NMK=c?X1&l=%%7&`b{L{Y7Hj!y@Peh8W?)PkNN#>J!W5j z2^%tmX^(at7%Dn4-rsgjvZ&;o1KKlxe|EgRUNL1nMk5LOR+tt3unoV zGf$_EV*h9&yK7PoW_=v0x$$=l1Z)hb#njt)Eo~z1SSU+%H-3YR(rmon>;%_W8&b6?Z&nSRU-ig@G+ExJaC=TRT*RK_zRR31`8INuDrm0|h&LqP zvto~QQ8*=-q`czey!ARz^79ijzj!m1tnwhcw_Rr|g_Iz<%L)8NBbo2r;Uw8Z6zvm# zG8LU`G4Gu^_4%NSV_8#3;HnCCQO9q5F|16DXX?|rKRGw!!VS#cb?ZdN7 zK>A(?u|I27EinmB z%iLsmn{v?ggd$=2EHm&gABSv1*o_7saa&ys-(-b4hQ&Fdw{bk$b%^2~EnC#G&Loj# zDrEbg|L|606f@al4}GPOhO9^lD$X6jmp>}tk&ri$Z2=M#rjPSAdaxpUJ>M*B9fs9r z@D*AggHp*E+G8!wSSyIru7`qjM&e&IQQOOc&tdR?`I61s@rbumD+h%2MsXEy2Jc$= zES~AoRiw!L9(e3ACe_Ido?7PvmmkSe&EZ_8>~}7#ueKmA8>WJiiWJ{!$}~7)mCd(X zc@0kpyR%!=^3ZPKE_l9Dftp6u;GScxP-A3|vujl8tJpO(ZihX2z9$+j6ZSAZ3q+~E z^G5KvcMYGuYsB^YO5o)Yc{&{@!G%p%A>z*NcS(g>JNE*p+-S+eVWvtbo13Z_%-s$K8L|V!G;CcG2=1sIGZ}2z8D^ z!pCVKdLn|vDaP|IUD=PR+b3eeX;r$XC5jd84Z?F@0vQ2gV-!B!f(K=~anLrLrZ^EfKW3my)6hU8rq=wz9Efnjv=d07BX)e6!58$ zw@rU=4fD8TkPVp2bu(P^*p}N?{DT1wbn&h!*l<^wT+COah3p7Xl74MaHhXy1et*zH6|_bA9FEGlbq15rC!1VxHZ>G9#;3<*k~#j}$O-{aGZ;~;$WAp~)J&4Xv1$%123Fo_Lj6xn*Ba;p(n z>x#2)*K*Hkj1=W-{DtNWQRuso1Il}X;Z}4Dyy5(Y-y2R4qtc~>5tPA{c`6|4w}9@L zy9_G&Ex4p*&xotwdy|SbC9V5+r3&i^gx0N`X2(p0TJr*>M5hM z{Un`sRGrkNSX1fPPLz|1raD*Ov4@_V=U(v?^|`Z3{!ks{{xQJFlv%K2#z_pj*2#M^ z77pSwlJUl|S3I$)X=ol)1Um$dvg`VnkP{!Z=n8KCRzFP`fBtxcMfFy=&rO;AS!&1U zDeuSSkt(D#T>%S@i?MfhrqFJ+U*O&6NVa8&lgBnkXuwD|J_32XbJUVC`?44`gkt#N z-+;8L=o4dJDtT+8PqTjaz+&-G7?SFRJ5Ez!hxB#)O4rd^S3?ljegI1OAE3UZ6{Snm zh-bJsd8G1_Ik49RcEyCC!|ZtGGdz&y4}4{oG|yxLoU6d&qYxwx2h%s2!sO(?D7@4nKz)=2=&B1j_~O4y z>=xDI^ytD(WXYR-aNmZ|)>_V)Cu~l4nsA=T7+a#=ZOv1>xQ7TFu)}AoZZlUmyo5ZL zm%RN$X7uE1IZ`Cqjz!<=Yrhu*NlVlrGW)$4DVvEz!|5JKxGcqe%iB<@%7~5|a&E{g zH5l~Cnygf>=NsOUfF89l>i=IBBhI<654m{b_si*|MZ}7G7k07_6rzaWf{(0GMKRAv z?HQe|)__x(7AD><6Kvd)nc=R;z^q*5$)G*w0E)r2`@YX8s|iroWEK8zSLN(Uv-#C3~EhKJ^q;0$Y!=8&znJshXfc0(82|FA3QNnFl1xYX*cZB0&pMcoQs?32I zu~4wc2wH=ep-AOfbiEZx4G(>Wp9LQHYws5{h-RpVvJN|`Ahq^={tBvHG?AV-U&#=z%xWh5f?nGy?v~?^4v7O`BT@B{fuWQ137jL@#5qB5!WFh)4y28Ka z7YJHU&ftfgWq3qqjFBv{q*|U1)b6k~`ds@9_BZ#Fo4+qWWr+)y0kk5D+kL3wge91q z`36HiPJmm5M``QAKHRnPW_3>4N9b-)rUN@xLTA}FI`agVcNM#Xo(E#c>8t7V;{Bsw zeL#X<$WtLlR$Sv;hRrp7XCm1AJ{elpI|z2AiHz7_I_BbTa>rGW`V<@>20!9q-{ya~ zzrCJuxg>)YHKJrVunl%w#FMS5OW=@TGOc1`F{5u2G4b|?w95gk^2kD>d}SiUJa5Gn z{yM~V|0`xxx3bpub^-PJEDpv78{l8{2j+lqGzqkPjSKc|tNr#Tgju&a5?`ri0TZAC zDlc9$yX$_#nsWA^49^tw{#7#CE1s}cO-i(Eav(0e=K--6o}kbnMSQp!ylSC3xhu4cSOk26cM@Ur z+pru7d1V9hmYzbxvv-*rA9T20c|5&x?UJ>izAVw1IL6MksAtnuMacEY-9&L;7skZ5 z@Cyr)DNLBlWs?;c&+CA{gv98xIuo+iN0NNs_W)etjEKdUux)MKWxmI68S+f}HamaJ zh)Qe}WkSWWakh~T4XnJwKdBl_r})T&iMAemsC|o-5BG8o+Dhg>*R#yuf??vvD+H8$mP3}>4=3LzR88pzV| zf4XEiJR40SR^pcVTJ(KQK8>*df-}d{(V_k{G|lDiloMRh^GXq_OFe;hpPhJd?mmpp zFaleSTgqMikx-LaB&5)3(NE(zfpm=@}PJNP0SK8Om3HQ86&3F$+1+O9}TLhUoA<=N9SOG#*l=0#= zGfIz{5DR7wle@nj&zB{@?%j!O@|A9Qmvk3u)4kcupRXC2A1utRUxIo6y@a0n<*;(} zKWI2~2Li3sXsY!_TsVpIH>?ikt&RSL*W*m+N~i7My#Fg+e{M=5np_#-XBsqgnlh%k zsDoW?2^J};k~4k@WJq-u)zJQcOT>~$+JaJ;30q;{^Bn56{TVh`Ou@hlUqDtY8D=d_ z!Pdp;sGp|~lW$C+^o2DkT(lFV*3QGf=Uo^ZHEmXz^YTt#bB-sHxPe+n&SI6GnozZk z9H%|<9J8Y%6XhPPXZ&hw(P?HEyJ5aOGwzVb1{xivE1YD>krN5%TO>mDD>aDO^>XHg zLor-x_zRww(@0ugE@U!x;5_{dQ#^hW48Hifn;YVJihKVdarb$AzQ@B`>csXUPyubaN|6k)A#c-+MlEG z^)cM~R216o?MDC8S5bS|7fSlKlGP8Ah?k-fz1hN}@)pt{mPLqp`9D@RM-xH{Q~0Ce z&g6}B8nmw3fbv;Ekp8ifEQ_r|e(k^7(Z6lH_%CKeSfP}s>vaRq4%OnrRnw{6YCHTM z@c^JO6y6$es-bRedNP3_t*eUg#moi#8xskdmA;T%P?5Tm`;6s9oKCu z0Iwu{9QIFQ&iVP1`Po6`*n-uah??)=LQ^@G>wYQ(Z<09XdvKeM9&1IL6Yc%`J1CSHE$|_3cVg6FYj*Ppoxpe??+(qcJ zAuVDq=1$g`Od~c*acsT93!H51i?6C}>9Ei-rpU$<#F{T+ocwj(o&P4_t>1saCTb7A zC#w!!K0L=2|8esu`=0T9@(PyzxPTw$hrs^3-2LnLB}};>Lo{+VaN}Y%qT8fM7hMyl zf~L(7E$2pyT1suK8`W94ts*ph+Eijbw~c>dYa{3^ybtD_>){*6v96eQ67E(;5TW5` zn3e5IZ}ac-1^tZ49hL9wvE_NRcZ&d(z0idxc=cGb^8~iLh|#{0btoDW#@u=(h5M>R z`0By(H1y6p)VleX*)&0!e7Pz{U+1kM%P%=&>Wh=)mf9*ZXJ!XlefiF~`rYR7@+N>@ z*i#gYwZN~p>}c9DQ~Iu1A8+qGhzGi)>A460AUIB((UiLgn@9la#*bpFl{lTTG6!w` zaChS#4WKdbgNd7_$+^ye zrSL&6Yj6?QteuK*=O+6*KoS0~cEH6^()8<#9C%&%0N!uOhWOU0WaF{5@X1|;2=v^8 zebzrP-R%o(vI^ojwaM7^H5@+M@j>mq5_O7-CvE+^7)Q6goJZmt^o*CnwKvybA@|IG z&DN)`_QI6^s2(~JRuW?_e>EbjM1Fs5Cm10}qpH8+x_uHPJSvy{=C=tp>j6Uk1&HhxjWE5<76C)?OFf`2YclT#zg^g;bJd^RBwQ>WdB3 zO;4QcXx&Bn^C>8F8wuPB+EmUF1@@T zlYd9T#|LL&x``bL*jou5GY@mQ&}w!^mooL-BnaNmYgoxFHN5J$2Oiw{2O_1iuvTXh zyKgj!z5h&`Xc|oBgJTGW-_<0Gn(px$GQ{a6?ztherI-VP2f1v|G1w|q2FR(AH_BK+=~LO1GhW=|YU#x{T&e6(EirBlIRUVap;;(r@=8aYDZgxzNEe zd#7gLgYxO*SaTz&-JXP*8o$`Tv3evX;Tk{G#*D_U=*Gi;5}+|K2S0N=K&uatWWR_L z9JaoJ;U)S+;NWMtTz{VR`&~doX1U_}xys*=|V>vc2)F1^4lH^~MAhj`1WtB#6!w0TE*f->dDsu6-yyZ5sx60V0-ZnVq z%ylUXr_o10E;9v}{g{atXF&Ms*X*V)b;kXLII)wx#5P%T{lcR??2&;0#%04Sa`6%O znYcdZJI_OK>!%Ls*|r*^+n!;F(>i)zupTCDzK;2-EjZOL376IHfu+oL5Up7YHuGg@ zDL3;^$mbD}b?(@lN7>M=3233y3-rAVsM!96y206`T1u5;iI3nFt_R=KIUNGP6c3T( zICaBvBAb!L%<#GmFPGMFJ=+bq>{%B6NNs2T)4oh+3(i7~!;hG;i~UfySdQ+=IfWgs z{Mlyvqc#VY+=0dMMQE4!9kQaVu-m(V_5WTAi+khfr3;6Vu8D$*O}Q8l#r1P^PJyas z3<%5E(y{yEG~~)({1qTfA9*e&i}r|+;qS^!^@3Uuv_AmfIi|#J1w}Ib_IrNG4Sg&) zDF81x&flD<02Fy5LY1qU!Gv?9{9FHsUGadcQ2Xy9r#MzZ4(~Nw;VuZL*Iq#F&S-jb zS{3#h0%jR%l0W6?^v~v5ek~ZpU#}KX7cUE9v@o$Yv~?xTwK<2GCQdZ;h&64hcPACy`sC4KcYOGLn5pPW z7I-rpr0V>pKVCVH`iHuax*p<-q!?Ly*6Vb9@hK zGDG(T$m`oF_<5xpwQ0S^_NS*2wZ#okdh7uU9J^#X*X29VRDsE6Z(*Z+2#!mpvcHN) z+2F+A%uw%Ye3QSC>U&NjzMp@hdyfb$cpeWQzYN0y_BZA(n8@Ubori6DM~Q(epWN8o zfg>LzNZgI{{3)4A=zp+`#B58$eJhc;jcNmlkjdD(R<5FKxxE+_?>@-s}DHB-0!<-{tQ!E^TH1#Bo8v%#xohM z!U|lo`#Af^c@6GVnMdV>u0rwh59r1|*=jX0 z_;3_N9jhd`eZDu@HO}411s-5b z+zW`_+gwN(sb^e|#o$W$dvIy_H;7p;Niq-XkR#Wog5;Poy}7G|Awoio()m%$hh`|` z$6&VlACASh22+pel3AiNNbos(vRvmTT-uXW8-BgVhCJ`*X(q_BYo;Kc{g}s$?-xK% z?j3&3F_O*j>V%I2$t(!+h;eHd2;I8`8h19~)#cvQcGhRObZZMqe9O>J*57c2XF0AA zdBAKeeU7{Pb!nT)Ij9lOCbECl(FndY8O&CMp?OC5@Ul9Uc2dR{Z`;5iuYr}`B|-n& zcLeV4KS4H0y7Pj5N5jP}1*SACE!B^m57eoFlRwV1Ls?tOG zC&;vU3dEs&6I9k;qHWwqvixF29&fjR{_%81UwszyTG*GW!dK>-&M$D7f12&NYD?E{ zJCBWc4Jw-_(zmx;K;!&M8n@q*`i*8_>X*DXWuiB|B|w0n@^gVmr^W)4QD zOgP?)4Ux|+1%ZVbjLwN_RFMdz9%JbwFXJAHb4;J!1D{xrrgI>gF_BJ}kK!+y=s@ns z8q!VN-k|N_IC%e;OT(qLVNUE>ynOZs7Jt17G7+NatzZZF+UFoFLV*l58xeWNhm5~B zgt(cxOw@(>^yvNp@bbS1kNl^StJ9V+pJH#r#9OCe^!WpfFs_0eU2DR%rQq*NNgT{+ zLX++j?2RW=IY!xTj(eSxVPmf5_sU~VD`*SwV*)2dF2Y$h@@dk3!+?Lv@{RI{0 z74V{I0(*&foX$2hAiX`idF5X=5Jl5IuwPuvcJfupKgmK^U&{S0Pz3w#W-}vvWeB}# z%zD0YVl$q3fV84A<#WuDHLo1MKtt=QVZZfJnUxwNXh2dzwFT3qGPt3&+Br-OS2y{7w%yH8NRz8~nR}90Xe8Au9I`JK^>_Xj}Xi z+>L4=a`*{I2o|$1xw-V(u}BQf&?28=onU7XADr25sITVC@A=Wr`Jz6v^@=m8M%6Zm zafoIs6{ca8vJ0J~@|y`)JxU_7){&Y6PQn*QeN1bD(XcWvx<4OAL+l7;`6VCy<#th|^-!{tJ8`qNBYziSo5c8JpAtI_1+ z_e*^N_4_oc?Q&#SHSqL9_;&XJKpKt4TD@4`uf0L z$XO>#T#~Ao8yT)7>027ejXc3|j$b#l{5a<%^I)gzO47t_pFufJja=$F4z@=!0SA|o zs zrm&S+DfZvE3eR# z%YHJW6XS`w$r|!9ppsoP|1?vl_XmEJnUI#u0a%f(#J1uItZ?aNzSetE`5G4A-EyL7 zSBH6(a;?m@_BS}`$vfoi-U&xVq{ z4vt(F-h@>8PFVTW8Dh-mqlS(;d8b;)e4L&~Ec!>_?A)^`eNhDq%KG>-2GnT$ zl5kSZ7|}Bm^?<);4DPzTg}!ndSoZi7{d9IJjxSEKe)pmsp8S`O&i&y;@Ix1S;@3V{ zW&aG~hUO7xcn&)!Ns$B7I3D79E9z$6fSNj0?2XBqB*^U)=JZN4I?A6Qnk;AQ8gtmj zt;KZWm>M1H8em^8^e2{`tBLu|x3vY0Q}E;ZO7@AyQj(rt$-Jx7MCqr zbzls<8b#21$1Li({WN1v9bsv+COdN|2Y!ty(jm(pupLsMA5s_M4`*%qA$t<>;w$h! z?Q93JvQzlw{YBg$p+Ki4PNvJtEXl5=A8}QLB1%8nh-Z3Q;dt$Ssyk~xaY)*MXNs@k z^Eq>f>g!N`zSUZ{G2 zQ~IXSAJ+B!rqRGk_Rb>H zI^Mxyrv*f<(v0ugx*Xq(#n2OLzA_S1A2XrJE7+8tQgpt`<-3oDvqjX8fl>y4YDtjz zo^D>0s21#)9)uw`li18Eh7mhAf$ICa(9fSE!ET-vtncx_Z0XbF;j0$bbYeTZVVyRN zPpQGM^jGZjsP$-9YK@jwO^ikMJ{<3A;Fa=X$(M7*xNng$*6%xlE8OK!+oT;djLCn>PNO{^eArMQq1bbFQU6QE0gb;)%>9WLn1d@2hx1yX!yQ6P@k>|mtMVuRqo|* z#vy?|>G5DQ=Y8NU8hl$g4Q{wSngH9eYC!#T>tkV$f<3jeP4S(Q++O_EkUR|bqNZe zT!c$?xh&U{csydY8~jw?S&tl_1Pwun_-5m5IDZx)D(5wG{)`pQ(iNhQD!3efNF@5+ z6=F+ejzZRqU4(A^28Z?J>624O=#=JJ)G46^Wp7?)wlbE)VB8-#!z;;B@ndpU{$xx; zj_@N+zC;J-0cP9wRit)m8KWd}6qJf=V0*!4OfyqAt;<`lD*`hwgn@#k1^M^;k4<=82{=Yrkl|JN%ydaBI`$1(gjoQLFXryVtx3v&_NCM9!aQ(;1CbYkM3m?VRvLy#{*pAV6K*F?X-hVS}-pUR# z{$W=1Vt5VXzd0YDhAaT-yGyzG-+;bc_u1xI&>!s1c#CGM&)@|m2Rd)jbM}7SR}AGC z8fh=gp{ryL>$|-Q^ehcXoq#MU{uM`>=2g{3+D;%}qOO3gK{ANcB$IC(%RKV31GqQb z1S7pdh&}fM`hQE%EnMehq*x3jCuE|_?s$lqyaw-wrn3$64&#mrdD>FI+NHV6Nqnv4!QZh8~21L zz%BW1rld#+FUxQ&)@eb|zcGu3{V71wW=`&hXEA5r=rESXe15^SD7yc(Fu8Q&4;vu8 z3TC)F;O^t^d7ZW`toxdKID7V0h_e5IA0CAg8OsDbAm;`bD*xcsHI2Z^tCIVZmXe(n zhuOVjLgW`SpPb(8}^dn-X7zAVL33~L z;$kW(PVNQ2eW9>vRUC<#&hZ)>xml3+1I7b1>E6qq*`DqMd@=BuF~9qa_f}0Aq}H0l zbkTD-_4x%j5Vn~9TiOTbWf$A`;mCPm91~F?yBfyv z(GCyb&p1c7&Nzrxb5`KI8-4J)^EmI*j&SVqk3+3H>hyNSGOD`8jMP}`)4xrJshO7& zjoB8!W@OLe|A2SQT8_h=9-~1b`nh4Q0WsuCARhfQ&O4)OPM-N5!t{xG;2 zC}cNtDgP7-T@{0U8xvXMelz+{_6eLTc?z$TbRb;DkZ9X0qLcSNP`&*LogYZy$NC1m z>gI_4zU7#nzMNz%ses1rE9k6T3CH%YW=_?;w02;oqS;%-v2XYbDqIb)rrhwKEe=Rf_l!j2FIV$7*7M~N$#2c_hTt!bUHwdy#Nu& zm7?dbHGpZC0$o`hW@}iZcXM9dK3lz}6ubXgF4+w<4c-hE=(_nXrtil7sRef4FMpHW zwo!Jwzl7VB>tx>e6(@c3zT1CPEJT(*m?|w@-Pl@KhEzwSxZITI%O_wm~?MXDO^+X4a52)PJz-xKNV|}?ki|W2YsPXH? zrJ^U;T_3p~4%aChIr0^Uu81>Zg&aSy_&7>9hjLEt-;jR3i^*>72Pw-0P?uB~`~I$P@ujLU9-6jfn*??4Bp%9%s< z#?Ls#&<7t*ieVa!)T!E0E?;+ElBo|>p|?{`v3mFA$>#e{@Ndg9>?l77WLFmH3W^6e znhZN`G^5zV!^AUj6|9|SK{t1c5dH7hnEl=<#JB$>@*)(;+0fJ2n%Rr}5_h5MV+}HG z2SB;<6^^gTpvBUOjMF^MvnJsNKl{hn8jiVAYve!rO*g4Ile6bDK$P1XJ4hxm#(MH795xvTlFpF^{~^Yp*qyv{QN*>5hBSBQYd%vd zL{q0XGv=jvIHDXxENi~u;3_-lUoHppMWd)9_q_;t&m)0g$vnJ!h(9vdkxqFznPchb z(XRu3^pECnh3EENZ@w_OroZX&6uq# zrV|P8Nc{JTVLOh^=e0ggg8n8;8s8yC1rALjBe7Fq@bfyDX!a31CJcc9s|RnNe8pAi z8ThDGocz1kj|H<$=$oWGeDy<~cp1&5BF7_e|6U2=-n9%~b~G~Hc@;2|%aF-FT|*PU zpN7Lf0=Vq)az;1&Ixa|z#_6i@%x|u<^fpoud0vK8T}h0-d1i$>0vtH@(-`l{-By0= z-~c1_@F$i_*0Otc?8dJa*;wJUgT1o02;Te1owg@fMG8TA0Ng?o7$lIYAik~ zmM5QHZy+rJy5#(>N$3}%Oefw*r+>|5Na4|E;AXdi%wH%9C6+vxX%hoy1ny(Wc|Ynd zlET|Wdp?(hRVQcuJ5_ts=k?g8>PS5SS!Nt{-Gr}R0KU!lAbuc%yO#o|sf(#BDEdHW{l75)P2wk~0RwV9ISwR5OpwG%4q zUg9{+@0gQ3N0?kFOz%tzrdw)fq5XonWPF)F9MHC*1z#L_Vs0wbyX^^jtkfpM18c}R zomSYqOdm+;1mYh18-_ms?6%aW!ZV-2A_Fbl3?g0ALmuG-d88U(Pc+y$Lnz7<+T-u z=gZT7A`_WW@jYz%g5@Z%%@*4(o+r5wudAgsuIK**j|3}ez2ITakJ6R8B<^+)gy^ZmQ~T4HHuQ@*zh06UZES#!nBp~w#L8?&U+tOXE7wmSPuha|GnUXR(y8csy9?9aO2Cr0SNKOn zs@bhoo5{qlk56XJR)F`etgi9LF_@%l!1)Y5uVVGfPBV_#Si@YO2v%Ksty*$Sa<-dq~<{4`v=upCn^53>JWN5FBu z7xlSahBJI+>AJH~G^F|ib8DR*_Wk8rx$DN5*Za>gI|WL>;FS=)p&){44gb(eLYLec zRG@2BchY#M1e1dcnEh5NIMbEeAy^!PH~De+ee(k9A2*Zd(%Oh44$-)5$tlS0EhYt~ z{%GyA74M2BF?J6p(!(L#ci-F(P1pO8)fLyM(cB$i9n;S?xGKPp!X6OqpG=ONS0!CL zlHmGC9F$IyrL#6Ea`&lZv@YLEx2m_0V-f%=)QjG`g|}>os)9wTF|bXp;dH%89|{ofr5EH&3CCk57TZtUx+A zvjNLiiZYHSweYT3iP#?l;&AmO3`tsZZqju4_D~$JOgn(FF9V3y&S|hHA_wK8qiMyL zHRMlsFbz#QPmB$BkjWhnQFDF~j=O}=F>w{D^|>1t?B2sxxw2?@!I@6eKR|v5e_(gI z&!_ozLFlOP2YdUu&S}0dT{wY7W1HeZRR4;qqy36nUU{x2+?DN4>up9QN4(J0YV zhwUE6>9hQ7-nymB(7(`|Y>yOW>$2x%LNduS4`0r-0t@bZZ=I>cwz=ytE=rAfD9;@0Y!u0( zU5_AT*AWzx(I9)XvWdZ-a#EeT8J<=C!EJ4gc;sm$37Hjw!5UMk+(soFw2x&kbrnEk zu@o5CYEmVuN?cv~6`j_FVh!i&6)jLAfj>w1TfU?KYn#f(PX=t=Jq4y@3^JBeE;8r6 z^1*GOid`|hgZ!6slIjVphwtOY*tfHR2`N!R$6kGYGWU0w#j#(0PO^hUndi*od;z-I zz8eBl6iM@ee2lqx4ZUB_AahFNSa<74@cxhM$?W}#d`$~m*MV8jC!}*!L@ZmTgwa2_@;ID-==W-EmRC+7>sXmfwhlas``WX)K z76(O!i%C*Y1^T*JlUEyxFu!UOog1J5*rrFewI*;}R99TF-y1xJKl3y0rXm{?L#|#g zM(agW**u*IAXCu<=}$H3e9plqQm#q@wa=ij_#SA?Nr4;rxunWB9Ub4f@+*bzGQWy> zq{>W~zFsRtluK?<%Vp_Mf6|%ke-;M~f7YXmR3g80t~B}tZvo>g*)XZ?I`(f1M*pr_ zu!}iEUOuJlD|1IW_WTtQmjn8>jN|WKZ-FAN=O5})4W2z8TJLQ~!b(Cf=$2DeUyM@QvJefC#&z(S8`g_?pS z_@VpIW8UM&g=Fn*Et(#qNk6PuPTO)KAaSE38O~B8S#uirl3X^UAn985!=m>rEBO{J zCk(>OeIe)@eT5aia|A+OPok&1FW|t}XDl;CoHoo0Cz1hcNSJO5jQ%vH+B?Om@d9{7hg|+Z99*E zG{+fq)tZe??t5uPwHvpWHonsp9hX9ti$ z)rolN;s&baJBhknaDgB1Zy@ej#_?Z1F#VTg;b|;GY$m?P`&nE*HME5nyMHkWZ#fR8 z>_vY6mLMi-+C0qVPa$uYhoRB97HJ&&gYvRA^u4_}Drsy)Q!_DQoNyJ>c8Af4<^`}n z^%}17k3~84LHw{og2asEuw9};bgfqs3QO7JtV=5N&Myz*@L-6w^m&Kh(#_~C)7_97 znFKE`ZpDnv+9_@R0&KbB*r&1iYX zNUfj8J14UY26ykYb?@*Xh9>ch$9hXzHYW~OMJm&rURTa*tHR`dz6dvmr&8hSG^i+P z#en5OY`L@?T5LT8(|&T!jx9bU)k=v>A2lN714@Md-kf$L5Sg#Vc+x;0=IQ$IBv#1M zIa78*fldoVKbGO~R~Vq>3I^gHE5nMO-^^;ROXYo+>wp)3fR3yCX{d%QO*>>w^fRpK z?#zeqsdO41mrepLZsh#F+LTxsUBb^P3Cwv%D`vI!e6m_Hk91dr6Z-8b7A`j;Yw!Ay z%H_c*&$%1zIro>rS7Z9^(>By@h-WpIogf909<rZ&@0KCz1z!io^Mt# zxBMmGzXGlc8n4Q6I1;J2Xf!r)Gd7n`d@RYc1@9Glkn}GbZ_8?sku#G(E>#&?zfFNV zYi^*Sdo}7uZNZQe<}fzbo0xFy#8S20^jn4)H8fAa=Or2BWrP-4{pB8ew@(Z=UD*kL zOQw=viWaoJOAMo-lIi&^N~TBb;1B6cf=v<>o^S`%h;7@*s*YJ?;`CFXo2Ww{vVqh` z`vmw(wSwJdO=$|S?}%Hz$)@#eTXv8mj4AGt8$q#?Mis!8$h>q`oomUWHemhMNYUKLvsa75_)q9 zd8RAO^)3n7kk8%agw)8RO-Y!1@D_EGO<_CMb>ijB3jVekyU2u0FJR85;scH2%(KE4 z$X27cYI`Syiu9rLU_6wRCo|fz=h+n_kKt}h8d2$a1%tNXEHy2_CEVV5v%*1UPQZ2) z9z2h>g&W`r*MD)CdJj4z%$a;eG1j9w9n*!($X4$^=rPj=>W5tLDDN`<=o$cBAv3mo;tcjJT?Hi{I$5>Wi%j|p4YFdxAfA8Y4K>eK;^<~TL-4uo>2EU2s5rtrPZl}xZ5O#on*IxZ2a~B(l?2+&3r?Mmi-R* z(!^;L*Hb;3I?kRPFJN^18sO#oM5?_|i(apJ$gKI=jIx%TFL~4-JKi?4-7Brg{-At( z@+28MGZ^MjNDh#%k0D0sAILMJC~8$fe_EcRGs^GcJ1(2yCC0KAepgZI{vpVJ7Rp%l zWHMuCbV%@}>&$_eUG&<90`|n5IGE8s8=b34$mLrKtn2k0w(-GfSUZ|R53X^hpHFQ8 z8+kdRa`iR5mWwB|id<;>s{~f(gabS4*jeU6O%*#7mQS~0bSxvNsKC?**D`j2i8Y+(wSS}&>sm?9o$K#NCmqkO`EKE8AtR- z^~u}Q_t3jYlsx^WK_o|36Om0TNUgLbh?||oxSireINp|~KJ=ugxeQ6;oxM1JQa-eM z9fTLlS{?IbKn?!KIwjsE%Xc5)Frc2eH*6W)8`4Wk6#W6Op4kT94qY#l?`?eiwR84RKCz5n5ZPMs9yzOf0wPk>x+$ zv&+`}$KHA%1M4;%1J$a@EIc?(8YJA=L+d(OuOcaqcRU6se=Fms-x_q;S7{otx(n}I z?Ev#<8PspldT0`nt*KH?#{Ho-IJ@>OdD5oqX zPkLLxTb#?My*LUJ&C>9d2p?Z9X|Hh}E~}|GKMGq9&V{zPkFe8y0<4>RoOdfzkM7F& z&af}?vAtD_ynCL^=IOnL;m#rELX$s)POZfaTxR7F8;yHegk8-HdAYX_ zdXx3AEaNxd{ACE1-+hV~+xF4h!%;X{LW?RUtfMF9ltaJMI&5!Hr>?Qu#9*TxmwB8? zHc!pQPycmcqwRS{`2#m=ywB$xX8+Lb>n@axEMYbbTVRx;9XYQ41Kvn|;knfKQU8EE znB^rw0{8s~>ykg<;Z7%-`rVgUCFx@z$CmiI?K)mB;ylV4;pCIXIea&8gFS!34Mq3s z;S$c1IpvNEcb2yTnFvY5TU_qaVElq-7&wnsKCLJe3!?)3{KZ*+4D3hoU<*b*@26PU5%x~x|!3eD!)=nh_ z%X^PN-x(Q*^Ax6G^-HPO-vs!5f^*0DasRJp?bxxgI1=$!5&9>V(dhkW;RwgEF)jE8 zl2^0fVe%q$>Jx!Ii?nIR6&~dsNW&kFnYdy{Jh^s7lGg3|2D@dCvVG5g;g+lnl$~Wr z<{R*+%t~u0o|6p~KY66%;(J)rV8yvSM98tKtxTboAUW(W!5R!Bm)Tc2Ul80=0 zp9K}!O}q%$5t6_-?S2dM{Em`+uDV1p?uE_LyZP8)+lFPsj@VJZ1%CI7)BBJ~)Spen z>{xFCFOTBu2?rodvlvz>3_$j@BzVSlp;@31@m_Tgk0#g=3$4ezx`dx_FMKIZcg|;) zoQWoPF7L>g&e z!!`CyQx|XjF&FZ2gCc!yAxbtSg^{r-%2Z-n7Fr7Gp6{Ro^avu8BGgPSE4}$B8sOb0b?fyAx$<2iPA$qBtO|PtV5)(rsm5P?Vfy zE$wnZwy^;7)hqDwlc_Y~X)b<$bQpp{M9A#r+GM`8G2LBo82XdD`R>(mMCJ##17GS3 z1xu^(r`9^G`Dh7-P5YSnGsk$RwPc8YUK8uG`Z>k~*77?R9H14ho-`mbg|4|$gu zP(hz)$`EK&1kSDIF#DGyttsSo`#yq@ zuH1;{e;Av&p^qNczCUDPG04zvRfmvc)@-SY{R;bXc;+&-h@;PntKe_ zw?C-K>Wl-`&%nnRm^>bLYF+oTgJ$ z=1vdThc>W23VBd;fZ)o1R&>k1%T%jWfrKtP4tM^H!N{=*iCVy8jNt?{#%}5=3Wz%-@ zURo4(<6l-`vmyOa;YTk9`;n$t30Rj>2UjeYvtt9xVehQ~va?(iuJqnVb>~`)%ZxzH zGYl;yr_iNC33le+fSEz6D7PxdW@YSaIK(kMj#dfLj_gJ-3C~6wZsr)ago2-S1bPc5 zlfn)sT)pTioVEGO_8O(a&!BX4Y5jqsmD_OqR|<{OywB9?M&j$89Lx93UkLgej(T_m zUNeNYaL>tGf@*Y${$`qQ)xenKa#`S8cEmWQjd7l$L?=>f5bAlx+b;5svCq5Bc$yA@ z;srAz{AE6o_p`+fwpLWk@)B5@o@1ufu3;zfyy$()Bb0eLi7Xuc&1SuhWMkRm>;}iL zY@TTodc4d4qm~K8tuY=Yp3Sh){+hu#A}%6xVFG=2rV{JKXE3#?s_^3RcKXFj6dtZR zgE zq&q#w(5{+u9eg@TZDLo`)dH4e>5JP~o*77DgRa5j1@%zJ<)fFse*{?Zj163N7%O)F zM1i<+5IbYe^)lKZ4ZF+t}h%< zQSJ+q5MBctRi@Dsm1mfJj_UYl+Hrc@iXlG&57MK;GUUXnzq~DFtHFACKHQHrK_9i5 z#Mdhecf7p9%&+CRGs)(J8%m)F<<2X2HA%+KNkn(fHoE^@DQ31MQprMNDl>K*e;t`j z4#Yc>uILtM5qH9`OM029_r#z*b2jOAI|2u;|6$y{9I2PH9T^uAAlHsegBQ!VzAp5F z>y>F_t<_d!^hM~R+BIanjyK^Q_m`k$Pw!)jU=FZz&T}lGJ^ZmRB9Pg*4^agH?tY$YBbl~5M?d%iY6e9Ohmc|`4BW1sNWIN_F z3(v*E_Jf>f=v5Y$Hg>{|*)Q2C((7rV9v{ahCgLyGgLLMf2}GM0Or3eP(DC;LoH)3T zjhm-HZY2GLoO|MQo1Fr&p7H_@6up8M>!0A)kt(=ny_hf+IhcFmI;cfE66+p!Y&gA~ zl>s4q>yc8UAy9uX;X41kpad7#03e4Hj$)q_*(4Tc> z2*nj(R=pFm)RiG`!XtEfC=IVfj^KZfLrA^UcepifMWw?|as50#^R-2Z9L_-IvDNjO z^7MX?->Ju*$u5Pz(S4|vvJ4HLo?_KqxGcmIF{-|(h26ch8Gjv|!}nR5PweYA61BiJ zWIssJap7AiY`>oE{lACi`X$)A3DJs?u%3 zx%ej1Cu)i`cIz8|5Y22k&>#aGS5yv998o`~z z#OYsGZJMxmKDfVmi>9T3nL=`CsLN$UWa5EmAi-rfgK=H-XXeDt4m^{v4l7>C)2X8a zTt`EIGzku~@8YCkI|ku8+r zIB&=4iI4N?j%+DfsFg}uh%ECx%7W^+>R^bGHDncOz~u$%l!S8qoJ1BoVy^I-%p72w z2v6yD|jWKPbA@U5lPB8g(D21K;!2h4-T z00PtL?pa*Nd8m-ljnSq~FDJnm?!hr;3iaT44bsU&IPWK))$5&49v*K4LdJA$<_Ier(7nwm4G-RMI zfO{sV5@H{phyop%Fmkg7ujY%8P&rTfN#r7v5aS7QRvZib_e|#KEOn|=!M%UxN|W`v z6Tp5=Fnz;iCjKQ@lFYFh*rQg4qoTiHFtv|WdOQh7K3kJj+jf%!mJ1-zk|7I9rl77^ zDwFOcN%a>e!uc2h4AQH^D{34^>8Cak-Z6vO(mxeUoAzS=i-T}ZnR7^|Uq=1U z6!KSY3hj$&=cke;bbly?kM63_3C~(t`M1V&>Na0iZP^0);=fXS@$4mZ%W;h9k4Nwu zmmfUwpD9SmSVMB^VZ6B86B3n#VRTt2_9R#!;r2$iZ6?z*eP7u8ds1X<$A7H&Yg=M3 zKL=V41;V>0I#~2|8I3F|Wt_az&?=Q-XE+++6<$6o)FDa!8xo{a6K(kudnT|+x%Id{ zY8PK!UY&}yeP!Mb-=?*iK9CPmtV8I#{d>v^)WzS2p+dC#vJ|j#=Exv&M zHAT?3+=G*9WXX{1OzNjN8LqyaKn!osBKOPd(eQ00`B#<9;yIN7s4JV6w0Z>vv@>O4*3giJ=>4i~Kr# zIV%(9+}H;1 zjAVMaXZwEKk?BPE4><$Y;-O#F-4?V6mqCt+vb7P9Zq7|Z$1`T080 z)W%#D1XRmFR7481i-)mioj;LHl&6yzFWR(nIhX5-LBs4q+~HJ$f?w3>^{Q!fbi{yu zlYWiM^Y5~ruS{t1GdVCQR-yhYnsdWSEG?zd zEJR8L8g9G>Su!yT-de;;A+syBI`Q#c|a~3HK8I|-c)T-F7;TEz(gM6WAK&hu+rr_mSvxU>KDIIq0E~-o3;gdqXHT4 z94lI6#L|({QLs0VBDu8&m>`sn%K!Y>oswc?ng)Ygls$;+?FOc?VF`J@Y99H%xrPgs zTxHY$E}=EK$tWP_g?h%(5TLRO=q3kZClhPyySAVAv%Cm4J{>?uCl$u2B@lm1@+4DB z8e!ssx!Ck`65aNm+gqD$#?qI|a6D2K9xe-@7rb;yy!A&o5dIO~B;I61`vh=@`ZYXssx&rr<1cHCldq#YrH>|jq1zVCB;(GEA zDtzI?7d;=;esv80+cuBp%x!|DYw{uOvNZgVXaTb~CbWWc^<@^#Bc8nJR5ss(6`Y)b z%SI|0lhw0{hwWjqwDKz4>E8g^jRZEWI)i0VAyi4=30p;tps?f~HgYS!eMY{RG2sKR zR&X<%c$7oaCiRa?=C&`HUY~*Z-cUQdiD? zU($oYS<+EM})piUXi7z8BP6?A% zt8|<7`HM;2{Bi!4^Uma5$vfuQX({UUF_st_e1j90@8PM9r8KPf7v9`(1_wC);bJ)- zT6S52zBx1t*rskK8!c}ZgTfIF z{3zPSJZ~$9?AzZk_fKs?mY<-b_fyDYQQ) zjhAC`Xx-H(jIJ}o*jd)2!~q4eFeQ_HAX5Tm*CQc&Igh?-G~`@$d7SfM4|-7xvij?P zOy56YTCpSp%N$RE)N4OB$N2#N>GLO`Z~uXxt?NdhGRI~$<~~&CdV3y{E?^NbZ6Sd)I+05Zkh$&M9@G}7@BJa`oZS{u%??UBkD zHmM7SB1dqByas9Dc!Up=tU;bg(1`36;8!9~rOTs0IItCW29ARE$W^BINe$UM=L>$% zm_QU?=7FQuZRo8s2D#16nAk50dQC4l%-c~|@_m4DyY`mzAMe1_#?#PX{TZ4+rr$8< zW-FC0rnqOlKZ6~f{E6JmWu<5`tPc%F=Wp4}@va%TZ$>7>aeRP|+0L|s;dap8dZa|n ziT$%QmCpKTP6m`Yj`HwA=+(N#w12OLgGPs-4%@OnR=`-qM*2bJ zBzK=%OKc0>iLBs6*kaMezH;%P!lv)=^CmZb*~`Hiuh^65@n!-O&=Ae;YuBXf)?TAi zwnsDJVQsKt_6(9%b_4@24X~$Y&tarIOkozsUwJH00IKiAh|LzxU*K>Kew6A{CxKmT zuz(Ow^(=-^OIbR9Is?W9?)3DxVjN%elizKh$j48ud`@<2fM6&J-qw8488hp z;Ri_#qH(~L;df;)Tg&Qk_T5i#Hme$X*p(o`h*39*qwLYk`(QXO4gSbHV0JG)g!=9_ zL}Ka`;Cami-|j=WZuA@P7_pk#?zT?S!dm{+sG-MmOxa(RqVNWRVLN_V4j zz$Nes-^+DlBG|;&m8`gF3nTDaiAr@JWBxQ9#;+x{Fp6)n>-KG|YrX@s?L?TT&6HO@ z(1x*mC7i0OOMbr+rv@tNB&80R@ZLH(p1l^IGRsk^qo1OE@UK0oI${eNt*v+o*P zWpfgJdo>83^S`YAWKUm&zlJVuN42;%m({-_Lk{K%;|kq6V1^s9PiUNrLCH}MA43}Y z^b5uZuSfs;hsfUj(zK@9oj6@9VK4qrhUVk9;Jm>VSeBAVMWu(CuPX-l<+`K15S?a> zpCw7^6XMD5e$Zt92g?!m2a#!6BXN{3nQ!$X$Z8OL!9z(=Nb0Tc!}FJ;f-pbPmWKHlkKF zOX<`7zu@%rFn)BTJ~~uRr1M2YNxN?VaT_(F>hENz;maYMNg5gdPy;Gn_K<4L?ZUZw z9jKcggIDc&bZ@#L_^jmE>kcY(*=h}#Z#PIsifn9KB^&T@u`W1#I||xQ&9HZuIq?rk zfhARf^d6G}7Ix|+N5Br$-)BL6%YJ;9i%7rrqg&V*J{!0X711|Al5=p0Z0N=q8)fXD z`P??A;4Xd8{DyOfKfq64Ii~!;5>nKGXlHQ`yJUl5&}9xp+uY{zsE6SzX2a$u3e48# zSe!Pmm}zzDWcI}EV-_@LfuWctyxPp=wEGg7-&ZZDQ21#SiLqt`$5Qb!=L8QK;5uc@ z8<5#1&znE>G~{fSrrs~r>Dek3u!#(0+`HUBf8$o#pU}fLEItohDS!k_o=B28?(2q? zyMV3#$c)d}jJ5;uc<(?qdD1t)d?=oU;YNo@GMC$qNdF5XGcKX!t-rW=uORu`as#Z} zpWtA@W@y=SoG8ssg}r-9*pTrDOmX22j(7YP?(qfKJHKB;clB?~Zn}g@osrnZ?T`ID zIa{J!1GaN{nt9$_*D{vdn|=AlG(OO$+ca}Ay=WBF8ef5^Bu{2E*yaMtD#^l;x1Ms=l z&t+0ap=qK98!>kcK78te8q8dx0@!>O3@s~X#5d3 zpRgxZXQFB5STdfgoJ-HuYSWmer~E#lQRZ=99VUes67>W>@{wmkD%P0LWo>UD=gCY+ z^}2?wAHOm?E!Bv@-dS{dvNm;G{Guiz?l`^WXNZ&K_Mpj65wh~e8d?#*jxhI(F>tID z9$iUfew`}9f-q6y_WL!nOz<6Cxursf#3Rr&&5RnY02)-}NuqxQP(`~S^2fdln%0Pr z19BJXYVTzHP<0t}dUEmZXF;OhAjilO&i82bfUOKwCPjG)RIaTaR(Q+Nq)h^3!|*+~1JW$bz`Yy?=}@pO-E+X4cAV|!X(}pFV~hKE^>Ha1 zI$N5?>7?MbfHw%oRLG|p+#QN@m%Vr{OgeciRyNoXzXNN?bSHZbx9-athkZg+ezlWf@`Lwr_R)3c{jiFTJ4 z@w+yi%TlOQ&BTdJQ0`{>T5&H`78O8G%O-U^2@6^Pi$`{-03%XM(<@vdB| z?PYae&J}umE$ar`uiFKB#Go~$l9 z2JB-as-5G^x-TlCPWlQ!#G}wF%b1*q=*P-m6?m23jGtZHh>316{L5vspW0s*lI?=v)f0uJLf>YrY5^) z&R*=T(IY8i@$mbm2ZpMaFiJeGS7h)A=ll2)6Qh18`521TIxfhWeZb{k5bsZSB0Vqj z2KM>?g%f7W;mFz+IH2!>4ibvwU#==-#e8R6`5~zHOO8mCSkTe%K>TJQhUZ<6u-PBR zfsXmod=f^UcN!2WD={)_U>v7$JT{q?1UKqf(7APLbg_j8?lTsnYnM+U`r}8*1Ya}q z(WM^Wy8U63o!rpnYyjv=wlIe0mlM6;huOE^f*Frj&v?(wOo*L9EXrKaB}VfCNrRax z+fE}HG%@`x!g-!k5*q61Bd=pwuMjtrR z-GzIx#*@$`TXd<7<~UPPe29!qlmU&t-#BYHiQZfi0V^IIU`$UxguR^{C;d+<-tM=> z?Hl}=;7nQitS5{;cry*>hIvwhg56-497Co$lt8BYRrss2jBGp=4gUTCU_ba5?P4y& z#y=7KMeEXN^G`2wc-bA4>TG5o&fY_|hFGzA=K?s+!9-gBLV^Y;%_SDCshFow&G34% zamm6a_JPSTQ+aWS`wkQ##{r;ig^84#3PS`x5XQUqGv^#b>Bb$B)M?vsXrC-kn}nw` zb6ees?@?cDId=qoJO>%M)VDB@Dhk$)*^s$i7W8Xog3X>xa4R+;&tKHw(Wm0%h*&%P z^(kRbe=x((54GuT2UXq|fjVMvsEN69?KnHM2eJF_FWad-dSsEvMYw%OjbpFK;QqTY z_@J1v!&_W`S%jO5wsuy$paXzwx7&;Y0pOaR z3B6TxZ;%YvdsQXVJg?!y3~8Evu7^GOwFk`)x4_Ng-0VfOik0?%gz=9*LDEGDT&C@h z&b8w7YGyb#%}v4IqQaQv#-l}r*U|NEA?MB#r0LwuOitki@c)xX^EYXtsAOAShzMq$vqyvkoPS!E@ zzx~HxM|&C2(kwhTe?LCnB|!(zwBY;`y67>d9r~4S@y~ki#rzihr~jCBDuqbb(*F| z{*;y1WRC5$EmYzBLDw{3&!Gt@xha4ID;SbU%L!DJ`+W>dPQxjQ9JhUk5Rqy;2V2FW zuy5Fx=n5|-`|qD%@9sMd1}`MY>J6V+i2({uqAg&-FNT@6T$fW-nQpOt1g{GG^j5-vM)7xW=GxIxMzQCWoYK(Md~<%4ry) zVu~bOJK_cNx7UEgi=WWC>H(e`cju1^iAf!SqF_!7cyvuv;F@l$SxEbIFCGawlB5p-j z**QI>tc}D<`hmOuf8f5~Ry8w;oM9w2YWD=sQz~q&>t4Epx0XC^e2Z54I#lIT5R{BX zlj?9K@@d;6=JVA&LL?_}OvEG5Qns46LNgDaRXFE-#ac%kR zhHOhl*>NMV$4|lBeMKnn&>JfkxUqSUKY)P7LF!{Tg`%H3+D6rK)SdG{c50BxQ@Q;~ zl?Rl!Z>IWBT5yFrmtQqq1f}}=gkhtQNj!sZ?M)D5^|)S8V2(dsApZXeV3um7C*O z>GBj8CG%ZrI=Mr2Xj85LcKtfV{88-!H|59B=PJoJePP5{YkUC7{t}*i?KBwf_y#5G zmFdJQ>$v`u7QMms7Ao$0679VzJUfmX^x}dK|DHV%o1oPg(XoJLK61lJ8oqR+=3lO# zw~3z56T_;l-#HhW811v(M5V_xadwpxou{2eJVq*TUiMu)KvcYnSZRmHS=kzpbkcJ7ZPpn}l&tkEPziZU---{2~wwuf~xTPA9-wYd+EJ zoWq-P@FJM4DT8yFmtpdA4os9U5I%V z_9Sbx72jxhfxDM6bTs{CndV`{EFGfo_5#NS=I+&JwP4b7%9eeYL6V#^p)va=O26@i z{PR}i@C9>lTG#|WnWv#i=L?>BbPmrx(4htGDg4lsP>^c)42whs=)3<>bRPa#es3JN zg^-yQQYb`3^4!D zP%PN2gmQ=WpvJxt=8)8@Q0k3e34JXO9R<=N%!J1lAWU2OU|qsh~C$6G8dCE2$5&WCpPE93}Z{66#)kksl ziFs^a=s#HcVLaIKey|sUlfopkT5KQW-%pRC@uHOhS>W6#)Zd`RJx=n(cC{3osA9!} zQ^u1I%C}*ev>oVwFovcK3lbJ2Mf8%xnel2@uu0V*WpeRox6TwTXV1ll)<>W!!=3Le zUqh=2`pl_Gp8b7(pN@Gyo8-7z5EC(f)DnLKs=FMZ@9|0Wli5Q!g`MctIG^3ntVhWx zOYq2E!tTmjGi$SvMEoWH<_PYi>0gh-^*OWP*mjlcQ@G2W3=uhxPy{(FT3^3rt8 zA75;^)roVO!oZQAeGf@=LuH>LTnpZb{TCI{>4GT!$RFaA-p_^gN3-EbwJ4dFHJ^z( z=0Kav7*2YT0eX-AEX-Z`o@R6{gqJR}*wl)ShFiU7L1}Y6+?sJq&>3V8-R&Ld?{kI* z4Mqy7W&vFwAx5l4C5Y==SzK6|L++oQj|&be5w(^SZkEwT2wKnu`i1||CnopsNva)10mZl}*k}G9nZ(;6LkF=+*g zT14Q&LwsNNfj4>a(T<$t<}ud~Tiz z%R$L09yjysA7@!>66QA#FI&o>)ZP7@)|Oe!c|f3dqi@O7QkVKdRJa2^a14$ILCWIlI4M?3AG@YcvvLt)CwYUdkv~Owjt;usb-lvqiE&UT`!4a%}noJk3c3@=VGp;wn z4(>-5!X`eGXD4}#%itNn8sfERe`7vQ7%NVeS(qY3$Kkz4y6nY0ah!JE3yoD?K-F?l zw)ntRa42&je3835Yd)4d8AT1(KgYjUJ`3t&McGyDWw^O! z8yBa(3&sDlBPumJ$=`}I!W~LgaQ|UEQGBqNuG=?_n;7y6@_0{H&ipv;;XVt<&v7NQ z?#&`1*D`5;@iH0Z0F!TxMs0YCIEVYlfy@E$D3nZX<@+pP?KUF+d$kPZ9Ol*%4^ zG}EiUrSZ=2DB{+1muuLfLUfbuS+V3AqI2aS+4{kP98TXwl9d{{ax;DM2#yNpd>RR! zElFf+MhJB^(r0V_JAm?S?O5~rF_-gk03TiU1wZ4-&~P<`m~I_`A%H&`Wev>l>~-!jQF$-w2(* zc9BoVf|<0gEBNn!37=RYx)oZnXQ=^jbo4OxSG#jdAE%Sv2RVZB4<`hteB@ZkbaCh^ zz7NN063Dsjt)OeOkUhw)rxtz(P;8z%k-NGI?t2}=D>?^p&ic3p1Meh0VSQ-o}O^qKp!eKwi6PH6Y7Rh`Nj|3X>u(R}u{2u3+Qz>#e$h?%qo8!iuk zHFwTp*S3XdYgB-<*J(2t^A)Gv@}Uh5z1TGW796wJfOVsy_#Dw8>NmC&gM%8V?*kQl z|MLZP{oakHrt4AA<&Fk?-sGInNoaWg6?o|@upjSq*|nC__IUXI%@!+;7^C^z!(77eLE+-f*?2eF4_vw+DdUZU?@Y{;tYdU(A+ znGLE1Lwa=#*JR-i+fCPE)W#1qU{@5BM+dOEO1kX!vQwPlNohW(xD#6Yq}b};hmg}< z2d>(88xFXSgqDa!#7@Q)d&aj2U!RkPBj57a+bnl-&*&^P?mrH#@*~NuAxR9>@#kLf z-!ijtYdEnH9&p&?5{d89U?cR-Q)T}9Qu+He><$uv?0qLm#+)*eDV@yry`0ao)3d4H zBV7o4d>N0AG{yxlF5|o+Q}WlGcP4(Cj*H*6a^g=oaOHV#?KR2VnW97J=r)WhD|wEA zyaC)|IYmpDHW zCyggNs5j5pk7-bWgWPy#@JExZ=$v9#eT8?YZ#Tw3vj7yl<@OF zjCvt>@juAPF~M&t{zRd50N?Lzp>rk~k(j$}RqQ`f(a+bqlZ1YADc zSGq);?mU6rjWTTNlIcX^#uZ$4bQg&*;O8V|Dm387Y;x!PcvdW!$+9PG!Gl_%I76(@o?-;{xN%0;90^Y-TWL>7%JGXvrC|=cMV6*Rbojls$_UF z!>{}JxwXO=VQh*L_`2@^k9~IdWwtC?wS6Nlo6!n+f(LXkXdk@FnM9(kb8*lmiPRtY zgvF^3X+7_*3^!(=eV`9+eXWM&o8xfvb9d%rV@Tx2xPyz)A+qyqHabhDpcJU!ylM5^ zQ|o&4+dPvD(^713cP0g2&DeB*{;j-Y8HshhBv^gbnoW|~Km;M*(0pqVn!G6zrZ+vq zIs5CGO8p>2f3t%}DqBdgt~N2=I*Kh7l_qg@-{EXq1y-a5(eP|1w$^DZ**l|?YqXw3 zW>|JpU9-#3IVK3#yITmNw;qG@zM`Z)fuHTiOJj;xJB(@^Kzhy!J`9ur{?p}BCG)7) z-y_WYtP#1=Py+5DD&Vn!=Pwl(!pT(=$iZ{-nBMXn&|c@lWIpP$8!v51LADoFI53hm zi~Zpu&oWrmUnIP$Iv!TetjDe`Xq~0d_pc#-Bg06$WYA zp@W>JTP7PkYsS1M_W~4j342>Zh5O1rV_Jg~j$b`HLjW(agi`YwU^b7Fm{B zlZkgK4S1%W6u-+|MFxb5%wKFXD$W@c9;I_g{CyctX#W};9M-|{Q|74t_zR|6Xp^Cv z%H%hnft69X0B3^UVD6g|(saO_Bi38U%8(kCaKam>g;7{zvt8JJCW@5hdtmf+e&6z7 zJSon6!&$Y@hDkMvB*gF^E@{3A$AeRZ1|_`j7OYr}#$x80zknsJ+Rv>WF^UDu2<9E{ z_h8W!J`eRf49pECv3G(g;H)v7%ys=ujg9NjUw<~PPV#IC!5a{0?!%V>g6!nkO zgExgJVYq;q*>n8)GM9)X_R>lx6{ynpBoY_&NI=|MDnJ9K>W~5lGt_Bsjy#*(Xvbpa zNI=i0P~vP9K$s_CQi}h$u%=Edvwn>pO+n0W;&zr6rNC5jESP$f1=$d(MAq~hl6e1P z;JN=XXLI|DU3KMR6zvw`q|99U>2f6e`yG#3sXRyT_-?x^*Br_1k^(4AHGtYm5BjF1 zkz1}kg0=XnFl}vJK2P$SCbi}Z`Z5*C_0?Xi^1^d2@>L!ALHBd_EVkgv*WM60_aD6- zFGh?fdz0b6&UE#HEO59R1Tw>$FjQqGI~U|cOnZf3;9>y}%SJ+O*?qb?xRuLUA;V&9 zhoELR{|?o9k6FDr*grvu^qqGgI%XlD>b4T9 zCCKE8v+9bQczoS*60dQV%+2yniVOM(wh3ExCg7OIeayo)Z^{5l?fsY=SGoy1A| zc$Z0(7c2VbgGsZBVWG!*Qn;jne+OrPN^lmKuSmiO?=z5n>jP(PyMVa8r*N2RFj@CH zl(6r?sM9`VXHgi$B}=lhlgF6&<~}+b6xas8V)o=*B5JI70LOP;h4-gSN%=cnlCXLN z+gWZ+dQ3~vTfv$HT5e{ODt6FEB38UlJRX;ZCSvMXSCSe2oEyEWfOmA2f}ge{3>H3s zf!<_(|6U4tKK`t!RhO)^GG|6gCm>2C0e+{+5&6|ih{(;ycx+q(hI9Hj^q~iW(nphk z&S*IE+JrrS)XMdII}YQ|NwAK}Y_eec9n&JdFSMJ8u$Qq~WZ4=Q_V?3X?1_}L3o@R~TEaho`V|Ejcj*QEp3=y9XAVL9 zo?$N9QIc#K+KMh~HH8i1FF;18fXqFBusctIz1S*6WS!=)Kk>oy9rD^AdFLduSRWTMzdSFjS&AV3|g#4QJ+{z zw&cG&{5rcD=f!^JLf$z-&HhSOaN`Tz87#>n<%U3Vy)8NS;HRC5U^g?_pG-HsQesTp zh#uR-@7_d?p{=$C-PM!|$FxIO$@vJ73Yrfa_auY2r86TR7SsG$yvr@22A)jnfPci2 ztVA__AH%0w`n^cQwkrI^cTZMYJ*L%RPTV@#R3hG!h6~R4z(>CUu2*ReJ7FZlcC&E& zsWKi`TW_NM7W~~(y#zA?r{Ifux6oq3AY4)(hwExXg5s?!Xo=$mP9@e1-{!ud zQwOf&kGZqSUd?onn6MdCK9)jrXEHcG+C_f~!>EZ*Bd1t*0&Lhg204-}X~%pLoc4$s z)~Df)Y5q7ezyQ}y_QnRwy|9hv!G^j;k!C& za!R^0n9wnrq)NUL*eV;7Ex(QgvuiJ-=!_SCaK#5pav}0Po}GM+&eOFce|PaaUpEo3;JN1s`d7iG zYAwwi6#_>B`0S8O6dVo~K*Q)gOft%mxyP2#JQrT7Bk05aN-eVTZ!~lGaUJK4h=Y7f zdD1#^kx(pES8&zN7hk&^MU^eatZi-$b!$z4OKSvR7xN9xu8OeNYTv+X=r!y(QADES zPlI-m60<*VLVmH$6mHh+ zXPc!3IDgD{f#1hWZkoCo=6OD*`%gXKCXH7i8??@H`I2`9HM}#+;rJinw5V24Id}?Y zD45XVC#%V;$m{r4Qwq=S+yQHsO~ly1K)6@LbC-YL!^}KgVxQd14bRyOYNGGJsojbF zZ9D_lqZz#R5s>Sj)uH;we)M zhNx=10Ea9)w&-3iwF?V_>2~p88mGtSH7^mq*7MJmS^3$(C>%s{rIsYCWOjRcZYLlT(V+I@lSp;?u&cfQe+c}d>XX&$u zd0gr;HB#^E!cw`JEOe*C(Yu(b-VU8D>d;ZDMQ&Gm!RBHi z3>ojkfR|Bhsdp02Oy7@H71r#=y;$16trGh-5^T&gVN>6#lJDuuSw*-mwkaJK&Wdni zAN|Yiq}Qz`^AGEQsC>Ba&TCtE$nQ;J6xGRu&XuSiaut_g`9Hy z%iU1$V;KV}^y7lNX#3QQ6&ft$w(K6nF}u&AW=1y?Ru)$6L zeZ}4H#*(#s->Wd72m55_qD#?ju9xr9t*f3y4xAAPqx$@Kh9b`t+4vq#jiI<+<2mi! zp-o#g)mYi$)wG!R{nZ$1qIK*zko`LmUi_(q3f0Zz&|lsY9^4Gw7eCVVjmL210p3a1 zLh*iNJOt_}ksUL_c-Heo9?~eFe^mDoqn%aMr(-ld{8@?FdA*1J?tP@=)_xi>R*U@k z8jmTlP9$)67qgX!g+jMGFwuWGNg6FdrnyKEv%*s_O=>HN(`x~hF;2u+T8sOZ&F=?I zkU5p-vh#06_e65n$bS(`>T|H`8|>ana*=|7CT zF$v-?wo$F9B}`9lBn#QxMbB7ilLx!1!26stF|P6_fAkKLumLlo{BC zM+nzlMrUpOfKrcrQPJI(4diJek>U3?6-uo9@fSGp@F+GNz03`@c0=y+UGS;1fqV1L zkiGsg7U%uGhSAbf(D&U4zAqVwuG#|)zeY`ACZf({+w76#X|^mA%X`CTT|(G0=jq5s zjKP>K$_-zY1IWK5{Jcb z);s}yH`hXaXDo3x)n)IK80Vi_0q>%YV4~I!(CWSeQWE)CQgoCHJvIvF{cIt-zCRMg z*sj8VI{9qk_XD8CXWpE}Tk)}WlCW7s4y=Ewf_sqztT7pb=Kq#pf3XI=HnkLUs%PP; z&}h!%Byd;wo_F_d39jf*I(D6VO7~ZfCPA|Qu=__Tv>(bQ%4hgqxs)-B8_EJLQ3=*u zAHYI{?jXAOCf*!lL5{CIM7`1?aYgZM&?;Vow#B^fJJgtnKb=VDKHttIOql_7aS>pr z`vx;aHL#&=3EEkI#E&c1!(inYcztgq5joHgCeewQYvE5EBY1Z0d3|WUZHby6#n`Jj zAG8eig;oD4z}Dx*?7`6mWJ1qhjDNHMyvG?6h2jU)LUJUD4=bm=jqkb2wipNxROTv% zhJz$W3PIzLE8EVRY zKs}do)VY54g-np9PKG zgJret{EX2I)f>h!O9vzP(^(GYMOE0A6@=Z?w+t%eZSp?l^lxx%w)%XV@dtlL^AGF8qtp!fi*VX zq-dWAu?bIPk$-Aoay;eozT89i{U@;Y&`o$n2YC#-Id`otgB@La8v_iA8=Ou%U|EVL z?yp|L-4_|jB1*q;gQZi+GM*`0{;OP|SHG%3FEfFh$@mGMV{T$W!55f4yoVM{UWC^x zq)FNGt!U48$D004WOdY;*e$z*8f}>{iD!tW`Wo@a;sMNEl}L64tRf#u8z?<=8j??_ zO|9Fz8#HD|vp>v??AP85;p3;`?)|M7&aD(BCTlOC#6TOyj&z5c#u?}%Doa)e=(0^# z4bWnA7-py@(6Lp$+~Iw1P(gPX6zA;1?m2bvvPy&P(5j=!3wfTZ>22X@GaXj(_8;eQ z*p`0CRHfT~9E1s05k!9H6+z7I6!g%xVa-F;4JY&k_$oe^3tFiS%qfiJp4FtPs9So$TH-A6(e{1cDdHkSUvx+gNf%$Q2c0X!<$0=G%l{-?zZ`fWB+8kX(2f)dc6Cl7g!>GlcStt4=H>9c zf`?f5!;Ib>@f2;2sxWTnH&E28z=b0OWcdPhcKG*l$T@rxx!ZNzg*kIU{lX%A6WD}? zKa5#}ODl|!Qek9l8eUJkNj>{#!=6jgAmgS&wv5zbG7~tq+bte1@HVM2OiwfTr@5wB9?0DL$&l-%rm&>g{kE>}MQ*%2FW_>$0yRp-uf(YYrfYlaTvQJchmDc^0Z{))>D?{gXZ1JN+a znXGHHhD$eq=!xc$5kAvN%;Y#OHZ%|xZ;ul`)ZWO>cYYHbQk)IHOQ&J%(|26ugj2Y_ z*9n3{#)08_eU@n&2y1dq2(DTtad{)_g+n#pF&qu)qn*}7@2fn7IqgE>JC1ZV9piU@ z$KmW9-s`O=BQQI|`|RG`gtOvDAVBRi&OB4jyRFpOxW&e#;dvTesxqD)KmQO!A4x%p zjw4&cy9!?3AH&uD5aG78lu)<%$C!z727bJH68?*KVDERzu;4dhY_9wRI5)Ty=0_Tk znTt1}Lg#v-Dwm6MPg}B03DT@qQI4$YTZj>p1IhAb0u~GXTJV_ONdZC}IfC{V}J^S`j$LyUaCC&i3%_1UWCrEL3sO(NEK6F1*!7pT8% zCcixokY!WP5y#OZ$)1OyxOu7%Te0j29p1PaMCV&EP2+56pP0#ejDyR-o zG=p0~HDu>x(B~x=P^RSo8+|^Dn^RN)k(-v$xT(hE*`!>~m5W3DC9+I7(h@(}^n-!X zQySLcz}E0NFgKA2=##CBVqR*jFxiTk#csf{ML*!!I3@TgEz9n_l_8!k)gUyQ4tjjg zddk7;s4(&>SJ*g?U6@k}&&3Q__Ny9hhouP9Neh8riGEO~Ac5A$!J*-2;E*zRH8Ki3FoIEiSC$_F`pA8Pid6i04xBMM?Y!aku0BdV9M z#$yp#zud~sVQCBEgLx43-h$osl;@T_7zB}SPaMBwJi8(Fn!a*1W~=-CNXQ6j7HPFc zFvsHnnR3aH8wj^1FT#@{c3Tr>bp;dR7)8I0%d+#n!H9uPHK#OpKK6XQ0Zr|bSn#p; zkn_zMM1P7fbv{$@x&JY@?0!7ldi@SlGLwmT`$=J{tpjF%8pY(Du5-#21h(x>h7->Y z(60Db-0jmQtgGq>YRVLX$tn0hoV+`a z-OC=qb9C0?r)g*Kg>5s9m^hKjY-^-pmtt7TJdWl0A&fHGOW7Ye5ViB;jMf(U7=$ z5u{4o$D4na(LYm7p_HFr)Hd>ak5W7K{go@%d((y-OFoFDqhFwWA`c9*6=iL1@=Uj- z8g*kr*zJdlSqwgh-1I!QXKf++4#k5vRU-PPE^u){EgAKEHuq;Zk!%p%1OC+~Id9c` zoFd`Kt#O(SookoFqgE-_{jdgB9IT-a=lw(J&H_>tV$S`?6u|eH9MLt;zzcFux%R~4 zSoGl!d|a!=AFN>lWiuIYeCCL<`xV)Lt%hW@hze7E_5>fE*~%stWwX$_qim`}8~r6> z0V5W_pxHdzv8*$c_}gve0z2ozU6~#5#V=84`!|krvP$IVDnHR=(>a*)_W`zepT_tp z1$@s?oH;vE&TQ=uu1kIuw1k_3M1&FfS*|0v?fQ?>fw|o1f3oDt969c7 z#91LE!l+IKtZY&zW0u6?_L}ji!}od@*YQ2DgEGwhhZ-KXS7EMpW0=+Q6p;Ad0n%m; zMC18q+Hvk69__G(H35Za&fk|k_wI)f`zg?#!}puz1K7f`k3sBx6-O0ra`UI^Vb--$ zxTIqOr!;-Ii*7e@zxNhU`tl#u+Ij%;_b%XSTGz98J%iBV(+I)i{js?JJvPuQe7EEW zH}U#)luy>-jQOne8{Vz8)GwIWzkCZKu2b2kzzWis`jKZ3M}xuA2i#}RZZuasg*TTv z;k8Fw`J71>{3zjPQG1i%%Igj^T)q~{4Glr8Z614cJ(<|@)M_Y!!;fb8sIwRK7qvlmr82wT9>&`IC*rpB9OwzHe0T|i1jXzz#n^3(A_+p z46J>Pa$VW@AlRAPGWj6>lRX4dD-h*gNwRm^o9IrS@!+-o3ra-kkkgWHL3+4|K6Kvz zC5zQC-{%>%lDv;w=FcHN`d;CvnHM29Fp%Y!iDGlYcT8*MyBfiatB<+`SG;G_KZ{3@ z4SZ&vYxs>K3x8v4)^|wAo5g4TgUME>4oE&Pj#cGuLd!c-iBec1>3`Ua&+os4dlk#c zvniX&+lCd;_U;^dUQuSlsw?QS=Lf+3&|YrJ=wIOO??xwEnc_}00~i>{#!o)l#Aj|j zMjVtPqJAo1tuvO~y~nd~q9QR&M}{4YHsd?3ui(w15!llcCiqopB4}=yNLrt7Wq0H* zVDk{){p{aF+#KGjDaQ ztgWL<+G07$wT0Y?ag_q&xNcB-e+WkFe8X#sxxx>x72uWe73%nYJqUcu!MC5^x!>Cb zsr9#^rN;#y?DK{n{GpOygTvx)eCk`aaf*vdO!FR!+k~I zG@J;B&h@b=MtqLb)ta;i100`r1!lOsc44V>Ud4IWc4oelrw{iH+O=>r7ozJJpw&CHFUu87bor~#w6GJGKqIp+|^2N`D-KtWWmLwP`2*Z2ARBz9UHW%IgiwGD6{#$O-I=8%gws#*u0* zdvu>|2l2;r&{|!Rd>a=;hK~+o=!aXJS$`LLFYZ8-vN6zQGnTxYnT^#V3M@AIIhG`@ zfQ=uo(*WgQ&i7;>{e8`AhM$kmoQ;}=gv2z;6W#CqDDi3|{UxnW zK6|cVYl47n=}E=m$P?)B?k3gD7)Fi%Vz?{n8t}Qvia7<;LzS`*e$340eSIsSx=@rY zTz8hc%&&-MXx&BK?fN`FdIab0;$c4n)=kSqeJwxuprwX4 z8{6rQ8(Ji%*c9H#bByP9kY^%ic{gwheHVX+O>&$A%0Z6&uC^2JJ=Nv9etp1@!$(0= zIutBZ4CyGxulOxL7MGp z#H??x;vC9d*ubn&Bxg|*eIEH7+IH;&L0TeKI!Dn<=f?=bRIEAETtlXtJb;~nVi>xv zo;$L=1D^d$BkQ^@p$`AQsY$g5u}?3dMRcL+Q5g^nrS#q?6c$+mT~@$-~pbmgH1Fh21Yu6Zbi@{^rdNwXpeS$G>> z=Fj6ic<$7_syLR-^Gv<=4-3+lh?0(le=s`T1EuH1;l{%^1t&dnxp!Ziu>S5=7PFXw zP_6(Lu3yQfzgtVY+pbY@xv%i;jsbLnHfc~xhrvJN(D%;)JidK1y>v|!?&Qy7VR43} z^uh=dxUvh+`;TH#eTU(hQU#EvBsjd_E=ZIf=32!V3fAle^AB}6LN1jo9dCqN;Vx** zyAK&6eOOrDfmQrley64g)ZPqd1{*EV;;T5BYj78vjYGJdFB<5Vxs1M+djiI33S{Pj zc4&@%1@;n)NJ{k}tvvr6BbM|EWcVKR{t_WRjr)tc{y&=;x0$=p>%=N|<)Y1|(WF&w zG*rJDhYEZ5FsDi7@O&_cXuV5E&mARbQ?5z&87n~ZHvV4QZ%Mp+8>zbf4?0S(7Pnif zam#(a;nc`RwEQ}r+&q1OIp^CD37t1w&V>2QN7tE}7)ijl@&NWRd=hoI|AC9;XIh)m zk3rRg5b8Di3DgQK+3^L-ARwv;9|C0Ql6EVFS_Pef zZ~yMX%(3s#@VE>K=}kka%Pp9&xdkQe&nL0BLmAx_f=`yc#;6h5^l*y?lfDot9I+B{ zuuhKkROpeAX`%exuvPe}LykK#_Y%f;_u?EAWkL285xnoJ$1e65GJ_PJM>qB}^m2;K zduAUNj2OU*-pBav!FAZM$Q~rOpC((q@1U`CB74^$iK|z{F~vSrh6i~DcxEhIe4L6B zkvt#B%M?dtO(!9@deGfp5+`h!g_72#_-u?NOKUD*O?PaGa=}^D;oS!r8GW#4X%3yW zOCPp(QaU1BmF%=iCjFCTc<1?B`eLFl-oB*AWcUug{m=pI9M)&1H|LYyYww`--%swC zQ!GBUdIU-@U&0N33%$(mF*aAY;D|rlupp>ZSiRnjOzwIP3r|QBhr%5&R!V`itzSd} zvu@z0K5=rvZyOu>dmN71T*Y5f&jjw7OE9;>k=d+Hftyo$abo5r_q zM%xCc9DALnOa2E&`&40HLo(M}TLfa!3GBoZ1P46Fg=njj;8NZNFMNPy=@I1i(aXHk z$`CEwFQRtEAl}$Hp5^EAdoDF)*j$!O&xlQfNB*XyWqT8Bba;bI;~kUXSwSIPSoAV9p*iVU1yEqzzlWbBAZO~-l?!hH^qqVj!zgC zl7ZhRjbXZJ8`;zCNAQts38w$?B)u}C#CFJygmzyVbyN(mzi>pHH3kB0Zh+KY2Uzhb4>xUW#9MALn6hv# zPCIoTH(7jzO;=2bYV8-gZBi24y)+%ycOOKN0uxp>cpp_XyP^B)Uiu|u4AD3Hh%Pfl zh(T2juDTZq-#ZV}5^4)Z!M+$dvG#&$yDN;mVNM-eE(`a5-^K|&Dqx?&C(v-s7fS9f z#(xR-!ACik)m|H<`;sWM2ySEDe-dQre@19EsS{KrSFqK01GqrnK0N6{*qvo-c`la> zzN}Ctes7nORXV}2%^;aXuYX1d)fNiNt%tdQ5lirM!U?)_pbi33!{EuQS1{$pa+Kc5 zpDi2Yh+^h;G;{VO;ga@X8pz+Tvnw&n1mDOt7yD<<>&t7#>)a;+{A42*@5(;GS66}o7?Ed^+04Oir2#q70B!G<#Q zJDA3fC76>LXOy|np>AN&E7AFI3bdvvvuPXi@QGl6K5l6gRNwhck3F)%mJz>c-&LM3 zRl;z)wGJld@?9N`2Lhiz8_=}(5Pdr!%Is^OgZPWfu;E(@q*k2;#T{dWEw?lApm`F0 zm{E>b?wi87kqs>Mo(Qx4wFX5`{)C5Ks!%O)kaxV!V)x~{pe?JG+sy9=_2+5gq(6tb z=+8WJ1{&a(#(I?hl!JnOqv)w^+sOtY-zN_)MB(}ZmQ_&?H`->y6#Gp~PT`HD*Rb)-v-ZxH}iatM2hE zP~DDf6b*qNjw9)kL0_g;HW%ZC99WMX;$DoMj!%l_K}Fv=`f{;?;C9vkoV%||YG2l( z<>FM*z5Fh|Sa*OjG z@x4&8uSAjk{yc$9i_*oI9V&F$%*hbF-K(-b@^3BB*$E10uBp3^eaV_wGld1m1!7%SAq4fiCNo`x!( zoOK2##ebl?TbjT!X#-ZW-Lxxm4spIXpH%4^VYC*9L83{luzL%H6h|3)@g{*~sLz-nBciz;i8(IE4l1d~;x14!-J1c7W; z0X_|Gr&0E!m|XB%P?>jwGY@aTOGmEaro@r3U+99iE7I|c>^fA731Bm#bK#KCl5FX| z%EjzB4X*_iFphr{|EK;L1|@Do&b9~KlUaS3wQm!;lplk3rE7v@*G)*rS5nm#rfmGk zHux@k42t~RFlIsok(QW1eaj8lLHzh&J&q$2;c7cA<)nezRZZ|~z8&58l_T$2w zC0H!!Qk=F zRM*A=4nLJ(FAC?fPYPXl+gc6SCu4Sqea4i?r9AVs3zu(S4EL)gk$Yo>^8DN*Bea#C zo@B*3Bs;-BBOBtnCvo4!cQg!mt%Au1esETA0#KUI7g_R738%&T;p$0yM6rAdXR~+xt;7wa7 zpExJ4|EZA1(t7xGLBy?SR3X#*LID_YZU~k}ldcQT2o3Ta| z4jmf7dUp0gouMPogFTKjpXK3};;sCw$r9^6%5W=QErOE^rm;De;>@L#_ZRJ5#R9c* zFx-DH-RROqBMS!TteOdA>`rAUikO41XZqtnm>Bc2dBUB`mnZ8DMzhTt=G1lPE+XLF zyE&?SN7nHru4tOf{`M_w(6g*XsrP@-YTaVYYM#wZJ!cXXVJ5w%UWmJk0+?A~7`Yap zPSgq*e(usE{uA|>llDZiJ7g>@tU1U1T~=l{7-932Ap_)6cs!B3G-ln^^ zc+$*&HQ-(;$pw&fwj%cjOz>FA-ZzFZjmanR`L=0n{Guk2wXCNi4x>fTkdTaoZ#Qgks_M-ynG?E zn|B4>7LOu#{z*WA(>R81I?O2KBkT`FT$JX83wG+F(;7G8HauBS{G$*jCsuLVZ4%t) zcPV7{@dn%$QwV{oZ}6A19N7`MmA;OhgQWZ!U6uM(_`6w|1us0!jn%veS2{S(xF-Z| zzUbf)nigcgi6+yoJcXsFr_!vAd-=?5C0(&85#7izJ@mX8((g!+fbkx1U{n?!O8O16 zUoV4a)|b((bvxO7dO%?CtVOUX}H;YG*n-xW%U?;#NpMRjF@4~t|chNsvHJNVAPmJHDD9rmgk+e-{ z#vTaMA$m`h$}GGV7VpRIS2mzmGp6tUL4g!v(_iV53zWn{J4OwjwvK(=l|d;-xXuN zfj)M-r{PcU$?W2Cd3Nz!IsEzL2~igpu(lBa?CZuAT#tbPvpx}z3ax5PKD&uJZCXOd z1y{knh3CM;;}BtqQpkmz#)H>fc*cJUuI-g#TRp$y!Fn@TV_Rf*c4a@jyuT1^Yy61s zxhv3gQ@$}K{}|4B^#6Wc3~qFE5~g0BL-Om=u*yM?UDL|K;qcAmPWD)Kz-zv6@7M{% zrQ;K4sd$8oxN5+Dvcq(FjW5@wTuSnr<=Bg^G(O9G7f$8;phgA5Tz_>L92{N-AD>lG zCEgb-RX2$5rwroW`NxGBG836QKj$#4w_$S83z_dVWqjFRfQDaR(}xb5*`=l)cp~RO z^e$qf37$bF-)`0`P*L;s2g(^xBTUE zAX>*69^DIO=n0BT)Y&h~FuK-XpZHFiL+(tdg@20|5mo*>aCXIL7SaBQ8~-7Ig`b~F zG&U(P^(F08FRhDUz98$$rjtD=8xp32jLOC0ehnWXm3rtVlvq zwD6qkPAVeN5K4=*ODXMA@A=E0JRhIuzR$U?-|zdqs5O8)eROz_r`eFl=clpX?*2rh zUHQyurD*Kvqj=n+k9C7d^tNmfyJt9snl>HAH|u_3n1dT*@#Z@`xFyTBHyHJGYXaLfyQ?WX-wo5c6pj44c2;rMLKEB<1-R;fB`r%XAO$mn&9y= zCCuF&L{(gN!K<;W5W9%Wt?}z;DjtQye>W`Xsvc!-w&04r>N~+i z@jL&Amo@pq&96P_MEWm;rJd$#5b)<6%;Z=!CQ-(uINF9zc5a4CyBPL!>KK>dIl`XQ z9;ODD+fZ?I3Gsck7nX4>Em{3p&`_N~i;U%&*%gv>@&zO2@2t%b@k0p=M#eB-d@4=w z;t>-`ReHY#NdDO*nAW4uOYoMYU(;semb-zxAt^&Jd;hu7LH;DfyxvHH%f3N+W;U+O z|I)NYbPMKY^YE2d4xDp@sB;A|)mfbsX3XNCYKwk*mW!@dC#>1))*d@#I+0$aKtvXuguxaC{AAqDco|x=W1~f& z{ej!tJiWtyPJfO+XbbFau4alOb!o|&FPPfm11r*gF|mE)z&}2V=3i`rq`&F>=m+_< zMq3k;4HnY~Ct)HSq0V_WO2NC)0270L!^c{G2w$#?fjj=;H>ca2$4P=X9*YGVrzrl~ zge6w-?mAX2LwA@TftC>V9@sH$73NUvFE&}nj*4>3(G>kr;I|~>>hSjPXk zKbEF1rCeCHQkL$1vlM-N!l1}!1Bt>`ycT&5tt}$heHv~IJUa(zT@KW-tAQ2o_9Q_E z_b>~#d|^a&lGv&0J2C$Lexk?a4!3*0gK1}Wz|)~p9Qpl--J^3Hr&+yZ57bM8z5O@l z(CcBC@L7wL=-sDBMx^Pqo2q1klOn7VSVJaD=aDf@E*rjK8R=Ht2Xf6nm>nYigxRIc zi_a;ecZU4nUx#x1z)HFD$`W!m-Vt}6~MHI6FXv^9Cyqa7O-0q#q#-?X8 z+fx4De{^9<7LE@ldNekX4qt~VxjjmBQ zASGCs#6P{wD*KJYo4h8Nk{1NR>=oF5`Woj$UWmB?t+1CW(8CgLbc>q^nbfNQZGWcl zg<3V>x4#)b;r1!?GU`JA_ulZ&XCA4#<4!(F@%aZepRw1I=91(YnHa092(McUFnZY= zyf}EpEGlUtovPglS(|sFLx3yQsG5%s6@{?ivLLP)k${cwO-U@{#5T0e!sWH=*{=mc zblBt-Xo@R?;30iTUbh+dDm=uZ4Q^DKI~UhK5Fo4KoABK)HyqMXCEqtH(8aa$@lAp% zICISQPTgNPJA5%Uf4Cf<%bQczQ9l3NJ2~?EmL6K{dVsg+EQndLlzipR`N>}$=&QsK zR<%qJKaOyG_>_61(NB^nKR%Du;(<-y;w524mYSyCVxuvB2eLeKd%^>Ph$xNWeTc&kzJ@67*;X>FS zE-P-9=hQ4OC^}V&42eA2GF+=L%45IHvG7)i}@~Z z@WbnEB#uxRytRrf`Su<)BrBo(av2We)I|m$D3fv^(AyHE)K)4aC*aMI&`5n zx0A}*#BSu@!-Gy=;Ml)=Y@-+AsT{7yVq8PgJ~Xpet|*XdaSi3%qo$(Z;?&RB4ZJ{)_p#srsx(qlmYx6P zHS4G7OH7w+0ln)QME2xG*lly2WtWOkVU13#{+q`R6}wQ|vMq39a{y~KGzE+o@<>R0 zKF(6#NzIcL$P=|Lym2xLZe2JI2JUz9{W3MO>hdVIM?7c4i;lx^mOI(L-;&9@_lzAV zd4VZGCtv{du_4NlrU-=4^NR~ON5dj2X?hUH(~Ry%mXO)E;^>{xO-&I8(#YkM z419HZC%gZ_c8py-$Pa6L2rV_YV29jO*w&IuZ+9+*{0cs%O6X9T>w0u3O%)#4#zIx( z7+Cx36Onvv&^X%yE=eU;J0TUVPCv%4zGF;|p8^&96U~?A`U_!?Ea@+vcT@BDK{9Fa zAd@84jvLR!FxzC;;JArDbM3eTX?|x81!-OEzK~g9&YIFvMRoG*<37BwYb!QT7qGe^ zOC&nxl6({=$|M>*x%^zuO%KwQeiUm>%h5>hHoxoSQcUQVCiAQs86R6NU(S~x(bZ1a zdan+T6r@nS?ip+^J_Ad zxF|ypCYY0--^yW$77v5(P9n0KDj5yc3*-~$1g!H*BBpEe@#u#+6zwvbs2h^0pyB5<>6J$zL@1=m}}V0C9WA(e|rOv(Xt?2Cn^!A~(PI~%eI z=S&b7V5PfCsZ8ky&Y{d=Sh@_TP11*@`g7Tog)G~huSedrj^U(=7ZCSyF-aCZfeS9q zr1kCd!1(cMP~WIcR~#wC50V^nt@;sc$u`4F>!LBiS%T7EdwEIC-Kcd@hWH5VhJQ|P z*&DxVnL~ri>1oru@Kme|23jM~T=D|E-(XG7eKG}6i$c6NwS)>kN335QN2S&DnDV*X z;H^UvBlEYMxxexze);$mr>@rKcB6@m{C}%3nLihsX%hx;?>yGdQ*iM3M!f7cjrc83 z<$p>55;oJ3_gm`;_7oV>B!L>5U0%x09y-Yk{HXzr6I`q>IF*RBiZdQPh_73l!C>Qc z{1l%IuIXa5Dc~Yhk1e1h{#RHkvI|{~&LFFER#CNSQXx>=Wz@MCEVjYh?}PTWo;Eb$%bM@dd7MLj_xz3xiX94$P!8VxzB-?H#kDB z+)d`H;u2>6Lm_h13DHot46G*KfoIYTChXK8as?9blv#nZHc8+^JwcqEnFEc5&sm|p zT(4nO4sXNgAy~FIkyW~u#$?wB;}VS|c9FzPa)EPPOuyqwwq0&ztv|*=`^N~>`JRvG zERTVwkOey}q?D{(nhU>`HlvyNFmuXJg!yJ7Lxyj5fd6uF;sS~AxwV*gTI3pT^fV+< zj3}4iYlSI&`^eo>j;yq5C~jBSgE;{n@PRj#Y|U-M`J)BsZ(N5#$3MVb_ccW2avbrp zUqQVR(wQxp1>odj#EyS0LxsG_t>@ycytc z)PZfQtYGMs5-Czjp^g38bVnc`TsCK*+5SQhdu2}lgq_7rf)`n9oh?*4aXU4w9zmXn zA@U?M*hK+L>5Nsg$(-#RD|E5}+^{%XqX7Ovoa_N_x z*wbQOD!)NH4SlL5sPZHunz4=Zv}_f@vcqy{FlNg{Jsm)f0ffFScX9b$36kKgMy8*c z%TMY4&L5gT0l)q+XWm&{$BX+po1s}2@0yAhsm`8GGE_MJW}ZF?ua|>3{VjBIjv28D z^kNOV#OR3Ab2t~nkg%z1p`@Q-ul+s-U0)2@T7i6e&sv4(7Rxuqzlo%_%xw0`Xe@Y5 zEycVh2}o0!N!}0pk;=#Oh|94U@cnt7HL~Sq(E;m-OqCPLzaGWBzz}xnoGKb!8Asf+WxEug%&YJYex+K$(aL0@CJ`_&m^+>qyB7> z!ZR3(+QphLN{1T;8st%13Dfg)0tweWi*WKVq~;&OZ(m=cg?kybyQB*_Q5;yyrWoCt z`eDPc4X*om1_u;UImgB^e8ctHejH6_u2ejP4GrFqC?$wd4%^A>qX9rZY@?U|mg3#( zoD+NYcQ(jo57SyVf{#~7k>cGwbm{#=w0*k?qw~TFV=Uuor-KL;P)H{yJg-CWM>V?Z z?oPVPr5pD*B$BKS2X^RGI_y5TleYbMh(zEvU|$rq+igPj%NDa^rPE1%>R+xKnGZo6 zi$eWDGp4$T^hiIzqE{on|7c6FH9eW5%W77cx&;@L-J~HJR7X9~|ZG z^Haxg`q(Y#2}+}>wY!*$NA>8ffK}w?0|}Vrokew>jc9{JBIsC5Cm(gEVafF)tl#co zwB6suyypIW()P@s`Jhe5ICvzu! zVlG6NScQvRW8eQ>h1+iG5rt#G3ZD!jXPxfC&L3?!9&JQIu1;gmUX3R8WoO{_eIN8H z)T5G01t6fwgY}PY;a|0-OqT6iIH5037CLhL8;cdRKVFp1ESAOoC^7c`YPzKdD-Y7(8F z5sjMN$y7vPKC>&J15ML9urMu@UH@qYwz*sLin%VBfPyJp8A596k%tl%E9utPmSp~} zG1zDE3G+`qVkE?8(SU#PY`NnrT$q>%X=^y|^TZ6sZ~t-Tl<7%E_p=ObI>vSELr$`R z!R~0dcQJkGDG4u?PD1vMZwxy(n@|y9lFjXe^|dZRh~YhOu%^r_X(^Jl>jO0XEd{;c zd>Ee?2x`|V@U)8|Y1Enk6(TPn@pmiZSDQ;F?W<=`9W^9fGREZ6wkq)J-$Dc4O@P&= zJQ8`K74J@R#4J(7g8ypR+;J{PFA>Yy7|dtxn$}}*>4_#_411f~{jvE;0b!`;+MGdWKrQ9%5xyyl0))529b@M_exxiql{Ep?m#)ddxc$ zT-rDDg!PZ3NNNtwOmD&$TUxQf|0um#X@!N_LNvg20W6GC;gfl4R6F1_6nExf+-@6M zx;Yps-*cJQjIWJLWxE0P#(@7#G2-~jm!2N}0H#MK(50$rMAB6n#&-+TE-PKC648gT z<@LDchZ`x2u))n-@49eTDEnbh2?}3zfVa^Xu#U~9DZQ#>q|$@$6Xi#jwVRU#Yus7= zCugC`Tab>_*TVV#=1{w$Vi=4}z(h48>fH4nTD$c@`D!@C^<2Q8($(;vZxI+C0Q}_I z3WqK^6R*Ew)M#-4-6Zh}-fuP_4|RozLskNrn*9$VCNIKsOR_+`^ED)QMX`6ja9P5$ z&8Ysf0bof0mtj^UpZ{agmvc-uMCSvu>V4ylU+7g}UP-VpSj=k^glp0bt-ibQUp20a5Ayk&lXr6GcJV^=9| zn5qm(Lkno{5zb$KNrQ=;t%GNT`Y?A*0V@1(#@920K{@dIOCR{! zd#g~+!ZOUho2g>dK*IlRVQVW<`h8 zJki8Nl|B0?1~zm{((2>B^x)Sbx-GziUbB(I(<*AjP%V@JoP3)VYN^6b<BJpGZ2Q>}I`Sw8Eu5pL8nWK*Ve_pfw@xkOm*aY~p=r7Mj zw;xQ2Ha*apPaiKSVRKDnF{`tUZ}%zyzYo5_pj<`BdVH3NOzFl=pBIt_!yJq{^btii z2a(Xx^+bJq5*fWw3|03mF;xC5+?-oUXDs;0d%Ia43QtXk&oeKgdO)!JnHD;h6}jJejk%* z*NexW_~84um**Y#qZXj-lQ|I-dLV^g!bb1=yPyRjB}TB z`>L|X_Ov8^BNn7Qhlq{{wEFpk%cGc3SvO%KQ92bih3tU#4i;yROr#&j*V9|p*I|du zO}Mhsm@GTrj?)*thLHjfpyORYzg`As)uN^kXVgf~lo3!$yv%<#H;!d4&PV0d^=R9j zj!&0Se#PVzu9sCve$7q8Eun#!ZzD=KbbN%nCaGwVZ$!S{71^UON23UA4)P&pw>X_7KAomVJ!8%1x1&NY_x%2+ z1kao|AWyuGf>O&m^32hU>P?tN>UU9IU3xX+yDzEGH_;l&J$h0`AsBC(#E zRVrLLf&{1Ig0*R6x1=ihGV3%seNi9Jw0kh4Q~Eg1+6J7jDggrnefWwl#7WJ`u(a(v z?}}pw|0H+EA80b8756tcDWZfZ{Ij+!9<{WS=@T+g12p9u;REpWh146dKBgME4{SY~i9efa$c z^KWhg#>`$s96R-?ozNb(*;s(aZg0Tn;T^F5*gp6=E0{eiQqELNb)~UJU#(WIQ6d7? zhe7b{8geu75N_pis5f46%=v-eP_s{t21X$0%-BR?^?I4iKjK8@dop?RfQMDpKk;FY zAPI~3!jv60Awmm_v9@J4_*hEvj)>Y&ztQ=u(Ud^i{8SJ3E;58%E|=2hH;&W1iuunj zXY+#8enWbQAW^w7feOjKVoskf=KVCk$L*AhX>WxR88&fdtgb|WqEa?HYfB^I{sZLw z$sSbcx1+f$MA+_y2}ErFb@UcXVxoQ3@xZbjcv-OqgHpDD$JzUQA6E-HSz>k5;i_M3 z?)Asqd|89GuUE#%GmT71jaZ|zi3*)$PnFRb$V_5I-dT8EdS*UDk8`2ab5S)zkzee^IP2gkZ+7D z!{^XKQ>ms0*R-*M^99q6H=z9m&Y#$?Lr&RGr5{%)(zOC_c+Tp>&@Ez35AR9>(N|wUWO)#M_`-=l zxAg_TwBsVi9LmMNTNALx8X)fRDfG!!CHp#OvRkubV0vc|mac38dyzHx&3GgEr7;!D zI2LEe-#n(zM-uhoe}O1B%PoF)lQpWDJN-87%@%3k8sd$nlLWXGB&w}HhIIq*} zRz>a3)_aFKsYqmX2{+R;t{Nj$Z)wJAk;JH$Ah z!6&YIG(^M(6=p>d*>zrIA;)biav6utOV7})P>|@I0y5$(i=J*b*$&@C=6OOJ6CL-N z=WcwXk>MS&5{+0%&Z>Ao&EB0fVooBNsQnH?jRo1kkHX413fw^s8 z7^9x~w14X;=quN-bL{xQdt(NNlFG2=)N`zxDo(xr8be@kEVbsqtLfL%c?Iv!f>zqQ zCdC((bY{YTG$TES4VS6LX>D&A;S=h3#OFJI?)^h>=fpiIIy03TJIrC;Jo$uYYWi?O z+b+8Otpj<#emXtp6o`Xwx43U~Qk+e;= zp)-pnlkW+cv`PLT*8Zo74_E(Y+TzM!^<5qMIKzcZulvpZ4RgUt%m2W-a0io?If31H z&5>TY9RdlKm3XUo8lBSXieADdY!54+ znMBy;Pax}$_+YSzv6d4g$+KgDALYy(oFC5QtdE1B{v=rZ=nu@O)~EipqcCn@j^Aq@ zV!ww2&6O^Ov33zUUfF`h*1Or!eo@+JB26O4bJ1154S%$>fY6EQblu4h=n$!Z2hu-c zAy1POPI|@8?_7fYPAY7!b0d3k+J5-E{RB0xt;a(_8?8Ev1gUApGV)2N2L4zI;`BaC zs2X^P)297q%zT?*X}2?D5b+jj4{H(kC93qOJjYFS*@^)IJZ5FeCq}GiC6QZehmVro z$m9|kF#Ke~->jPmwI$un9}_}&CPGJU*+&xE%>z=fPn=iw9? zX=)sAP8J`PV99hJ$e5DZ6tZ|TPBj+hW^8S2(cnqio2iVSDpjcd{(dx;kf*P&Hh|PS zON^)*B4FEtXTy$xSWiD_O!VP)X!#Jy<^F1$y4X-h9b#~9466ezIBrBIdwQT63@Uf? zlXqGJ<;FI7ed9MA{$vn^~2hiBTM*NV%gNHmTW?x55X> znNx+4d-K?@%@jxLV)5rdGRmYqWh*KcVZddMH)67ajMqrhyT@FJwCF_U zVC5b>;;cxHyDdW@7Ew*p0se-|)9%Len7sW3n1Ccv$`PbqH$-59lr8xjG#}!MjcMH* zF0(p3nSS-0irf<<`^m4HtyP?hm1S2kC_<5&5zZmcVoR9BIVvDG`zV<|*@?CBr(N&yt6@)X=4ntsWwS)Th zHS$ZsoM|36Be*4Eh7Atusjw8cM_nilyY)`7^L87M8v?sXTBjWSJX0U`46foy!&)vw zl85Dt6?R+h_dWl_5|Cm=W0 z2|YK-;Z5V4@O^OzYf>@Ji#gVU3(P30EO4d9q6`gEx(%beiKIFp5i0iIL!&O=Mwg?$f6M#bq#VpHWJ>Jzd#|) zO7#A;ikfjO3zy%C@cpe6m+$|`wsVYm?#zPSTjXh((|OdEl)<@OPhk9oHQYLHPeWSU zaQ070(pOQ>YWmHmL8{UCD{VJrHQlLJmp6vY*Cd^L@*$^Jg(?Sq!@ajxkm$bSSb4mb z8Pv2Vzn?y0gZmGFDR-uo-*;n9Z*-=K)rEYcIBD`J^EBQvF2m#}Tp;cKX4HC{LGQZ= z;l?p(`1x-aKX2s}nipEaQ+7+HEq4|Xl^+MW9prYV-&>VFdeO}`d{QULwIa0s&>4Qc zS~L52V-#6A_c*v-F(8s9(LjtZ;rhIp)X+;Ozlv%N03!@O+OUb?tLvjYjJ6s?8d5<3lZ) zf0rZU8W-Vxs1fr2TLiD0q-jdhStfSF8Ov$o8hp{vQ?zxS5ae>1i0Ol?A?(F#JlDC4 z+)wUg+ zry`MT5ibO%6RqsM1RJs=tQ|(bUB!V}`lPG}sK}i-x+`M~ecEWkK7D8ccic6}ZeBes zEfFL7y?ywPo8_Ky6(wuOH=syQCj=>WVg7SK zC^tJ~_x%Yrpr8*qT;kZSY3dw?3I?S=HF>K5vXE6L}fC4r(*yQ0xy6rzS zU2#?>!|HLc?s^p2C&HaGSa#B6$M#Olo`dYMS?Z*ln`ON^ zZ9__~-DS2O9kMcyAH!jd26Sy0#<@ohU`FF4Cik!vU0wDCl!JYtMXLiV(xqv6wJGV0 z&Va!Sj%4+%7Kob`L+)AS z$ev5*k^RRB`=)+Btm4;!jMioJE0-qD=O>_{ zpF|d>BV!zRicx*Q5cjG_p!t(Wh(Rq;Cm#gSo4`3JFHBC(l(KqYKlZfxq`-?wWNDH)}b5o4`t6S zXTB7ELfJwDQ|B8nUPt4u!vD{_N$v(#yxtZLTC4 zZ~BFIZ$wk+-KtFZGza?UvKVdl{lFV5)nN<<=kgET_kj@kKQMdRL1^2p%;m7EAyr3~ zuD730MCG(;vp_MI5gG-hjG07rst@E@Pi8y(a`8lX0aG-v1gnDI;q>*j#QNDBtX)9C z%P|*6HDu|YI1j#sb2K)ddCZRdXk)jyMUmO$AI!@6$)@+{(%Kh3JgpUCFktRX>9&`w z_<D^K!LA{fH>?tjvPOe%eC@i+*Cs(gRo|eiWCt{sjK->Eu#9w+A~lhbVdspkzWN zmOc}}{>MD1)~LsXaBsNlKY(8@h7h~xEIe5E2j0DwWX``VgAZ*1=(+kQYvT9-M7~aE z4L@#VyPl_@cCb3(Uw*@!I93YDW+m`GF9p9{AoNQ0JNPwkGMISmzzdyasQJkp?Jd8+ z)iv^1(0`8ITR4-lO^LL_XPC)-*UXAQ*uBQw38Pa)5a%AZ2N}6G|m9h44 zA~FU^P0Q}D<9tAzJALXkcKMBW;Qclm&5T-!ZE+A?RBJ|e+#7=9nj4AK_6i6*SxZe{ zePQ0^cH*>9DROH41$3_yq;fwbF!pv8m+3f!`8pf;2gnw(!BL9D7Bs<(gTnN$7|;QM z7tG#&t1<5VY(}J}3P&!jWvsssUMc*L6Tm4? zy|HNGQZ{kqJUskqfdQ?vF=xOB;*YxGS$kV_o5#sn4hQ3?Vl=22oM&cS1ZL@;a|Co_(*;Tl45ykDw@^oZ|0Qp_InD@k4gWho| zgjP`=sO_|78kQT-2p?lOZMqgGUx~)|?!%CFUxC`)PlMi|Dk}8(75~aJA1Zf1lB!lK z5-+Yy6m9H5HWl~7fpy_n{6GrHh40MLuCMrUXd$!7QU&+wJfU7vdszcHd)ynykh{0J zyv^@J%M575A9~aK{v8JYPXw;#QL8 zt5w*?^(&(9%_YO9CzIP=3`*FK@$~L-T$9MB&>+w99GbcFP}7FYpRY_WE)9lMN9D&eD-5*;5mp`T+v0Tmag%Sx1RJsd=hh+f>y_3(6F2eZe@IEUS#Ql`KA zD*k>l1(%prVraD^884YZUuzD*e>*;4L$efsoDp$WNTh9IJNW8#Ihdny5-#ad)iZ*E<|{dGTkKR=i5Teo}ogWt}LcOktVb^R}wAv__K-0v5eQ!UEp;6 z9pCD*EK@(7W5bxvAZ9#ea;-TB13aHH5!S=_dh1lGUD%JS1tt*r6V8-u%Lb7c6}tYC zBHI7Sf@l7A7~ZgmVCh3fP9z)BeYHv5j8VS9P3{coJq)2Q7vcJmwYb1qigf%@r-o&A-?uy9>#zTV~XRW95yFug1#h1*}}g2e^}W z1VtCuKvd>A_!KTfX9*{v6 z(H0F&?B271J`mZ4UH@`WEPEQok;3c100j8=PwINVspKxfNk~= zuPG@K7aS7Ab1hrJdCDXx_wgi$9Q0^4H)jhs8(^|b=Q8F(!DRuNOp|2%yD#{=uG_BhY{01_m3fVYF9sZcI^I5}iJU zPBK;^C#z0Ca;+O(ee@g05?RBSET}`rxf0aW@&LbS(115rK$J-N*b(`@C*?ni9#-udhsLU)Z7e-SH;P^OhYoDcb**|6k?ZKy~CKU z&DgoxhU#)`y3s$quq7c8%%e`iyL@--?2&>4_)Z_T)OLXQ26I6J}-P?0i zXh-resH#tAl;)bz{96WOPX^Z=F4LjGJ^Hl7?<#Q819%40NdK$=n0jsyEOl(jm$?+O zTx=P_-;3&YqYTgAl6|!Q57TmM4~zsLc$)3CTIhC;?O!HJBGcE>-vX!LT>M&+AeM$B zz2f8)GaKrSgyEXe0V*P?L<08S!F7|eF*>1uy3LTFjUFs>LS!b*x8kx9-9C&B`=IH- zvJZ^^?mSvFFM!M7=0eulPP|v+P5-_9jzv<{+&je)664;2vud)iq`8dE&1mLtTVabQ zJKFhJs*n1+^2x!ZSp3$ogM>^JfX?_jx^mGBDB1adpLN>_R0BA^$sCT+7o5koa+%T9 z_rmEnjY-g=Tf%N}zQPLSv|?n=DYl?)72Wx60`u%a9ayKFKzDa>R5ISjSgb5$6u+|k zoM?6W_ggyD_Zm^pO>6LnuNcSL+78_QlKg2HBrEomW9gIWu$ok2ihm1^6lu^@%N;a6w)|9Z1!w2DlSDH zeON=?x%qVVmM5TCFF__&O~SaqVb&w3u+hw{7wz6fOlAHBGC(~(qR_r*#$k;Nwo!Z5HS7y!+#1J}l|jtn?^YzqRf1YH3E@ee zJm*o2$4!yfnRM?V{=KB1yfFXCBp}8XN>9$FO8Xvy=SJ>sQuqnpa_^Yw$u-QlVL!g8 zSwxTE9<*MsLL6qNfTzDMm3bP?77PoKtWjBJ=Y;p-~hbka=7Q838J^MEZMRmj9V#X?w?ghOSGs8{=aS%h zK4IUIAvH{pxVOwD)v@Xf$KK&@US>o4?;+;|RRqHpu1A-1rLi>ZJNQ@3pkXT{Ncmzx zICN8u*1k_gH{+X(19w+e2u|U6y2i-j?0Y09f{5})aM^r)W@_P95c|R;pFOL$(vjofpO{2)oOpQR_&#Rec`c~3Rwm-# zIG>KL7148h%`6Lu#kXP>yzg_3Xy}ImnrNPi%U*nd#LqpTYNtro)%L;g%6xP&?T2+u zYWQa5S;knPi>YxtOxdtXJbSMki0?u4_tzjE3o>XsnL$729mK)uYM@$kA16#X#p}?C zBO7;B!WOAT*ch3I0wc%S)jw9!9h{3RUa$gNLz2jf5BupdAvGdpUcnf&gkjGeJsNOm z2bObAyPMB**_%od^ z*3#i6#|Yo30IK_>IX8ki4Op#1>YhDkLgmG2Zg?SVx46R1yhEt}okRGlWH#1Db}(&! zoS?o~j!YE#$Q&`7f&J}|nfaQb(9zt7hd4*=9iAkynIi!AjqPYgjy0Lm{Q@SYNn&^2 z34mL6?4lAs-1l?`RAzFsk>=Ad?QtS38+M?58y92ILS>>gi@Oh;f8pqhk31i(I!JE% zzC12ZoVORTWI5u<-g1&achQefc9bbnXllEi(&T2f; z>cUjq&L#QND_Bi`A>7M#>Sl5oZeJsR94y)i!fBRN@JlILb`NlV_GmE95GG%ij)0%0 z5xFr*i89iU;jME$JfHCjwX;q@$?XuTdq0yZ84A$vk#$VY%Y3pmr3(0!)$GFywQw_3 z3#(-~zFAT#RS>ixZyb@IXOc>|%LAV6+)g^QOSoP{5cD69BsKL5u$*%WfJhtKi*P>d zzav=d)DP-Q+#xk4ll}Tdn$nxac=h{KvfTVPmd-JT1kV5UX5LQfbg37WeCDG0r8CT* zkNMQ0s+^s4SDr54n2*PntOeD>#(1|gke4V#R(!1<0 zENhmgn|5oXMNc=l@9Ke@OS$^rg9P@Si2xOB3a0l-6spY(;PzSa7&ZSHZ&>{x?oiJ{ z7vD_m{yd1K6Q5#R>1BxdYR2U$B|t4&m00h!Bf17QEY6ug_Ph!Lxx`3F7VzYD0-D4$ z=sw3&^8~%3r>tJ|GB7$}%Jz;cvhPDJ(Wmt|Y!+EaWTuExlc5m0M_3e4?;vo+eLS_6 zY1})ULr_$3t7=kk1N>g>KQa;>Mva7+=5P7$a+S% zVf8W(Dj)Ai({FM9g851O%)Jw+<8@QM-{1r?&nFm*M~)-iIh8Iu@)FIBXF>MG7IxmX z*Es!R0tOc75fK}CeC;bnyT8S;UWVUTBb7VsveZ^~nzA%`z1)yUY^&nOtgyGQRK1OHUTH=A zn(o5wTZwe$gI4_FlL$^5^6~J=bByU3O)}$l4*ZyU6rUgM!Q*qPVXkK*`}D>H=KiAl zU}CL7p0D155!Y|C7Ig`jZupV?6mLcS{;)qR^8SSrZ!V=@`M*HZPlx&|D^a(dvUKy%e*S&QUvTVq5^hb6 z1m6b&*jA_j<5PxET0N6g-0z4&PvF3U2}H_bG0vL4m?>FVgQCks=;W3bbo|%Ao?252d6O&2 zv6xXD;rb`8Go4AhaSXni;K6DuW#SdCkK^Q74iCS&(yog-bZus=i#sl} zv&R?j%JJ?4ao*oy4?6Xg7lxSHk(GuM$>h0*z-&ndPHU9HYkAyz{<{*!U(tvjX>`HR zmH)w|FcWHa^%S_=oJkfx+fVfO2aJ#bp|WN!HaNwu_se+xCs{gpX*GAf0kH z*yjl=_*a8m<=j~3Y#edd0TpUEX(BbbW=4NsSc(gQb38t*r(YhWGBi}eX2S9n zz*kO&>aRY`^ND`+>Lp9!ndpm-;h)(7En)ilS~FBD&ZfhK4LClj4x-9mu$vzJNBy54 zMy0A7OtpwSCOm0GIjcqN;YscgIN%Rq2Zxvi$tk#5dJz6wZcR5RC^Fh{Pf;;kCV@w0GbE^H6aL_1f&i_#cyo-)}F#Unf1D{+Jw_GGzhv5mW-F zQ7ia(+MG-Y7b42bPeH5qZARyc2JvK~$)_wUdS0d-c28zt+w)GGp?Qs8W+qCy=l$WI z-Q3H!9jba&mR7<_82&9s7dSsuYi(-B6OdSrdzaRImc22b&Y+AXYv(DvE)^} zSvMVnp3NhAZ%5b<>0uZ+`yZ}88cCc#rNDxKaADI3+d`T;v{8#A)C-;gr^R#!ad1T=*!Kac=v4` zS6g|ED_4Yq@9khlJ+zMRX!nsl*3Lr2W)+-We->u{XA8S~h8VNKRycg{0IsXzSgx1O zk+JSKyrA$Eu<+gn=yH#R$_dW!ZmBuBznh2lRVQ)rQ&Yw<>;YV;K8G!~_GD+2HJz_{ zmfs*#NtSVJu{)8XkSj9n|BBATAFKC`&{3@N|c6-REkuzgrtxt zMRsI{%*wYkc+PcKiZUCb5S6Huw$W04=l2irdb~X6-1l{TKJPcN*!3LLc_CGt?;LF6ao)bVnV4Z`VG8+U%8tZOm+u{T_y~xHH!of{$@5_Bm_s#GQxu{ zq)hK2xmNNOcGrke^WNzwHXupzgZ)X2mpQKQJI!fuQwIZFpEJv}#px`wspJs52abICz{v2Hu-h-H;;%XD@x$^#{5Isz`B_*DT7Qte z7&Zde{wPutE@#3m@gb)`oW%1YUPaSa zwfRt*or;m0_R@WyoJme-JDQlBVXhU_FqiW=X43jknD#6d)Z*7OhsrK9n?K#auys11 zS+$Oud)2`g*A6Hf)c~P8C6HYe2!bz0pio$zw2k+mYStty@hV}8-Y+F;-wGga;!=3* zAVB_QX@jcCJ=W@qD-K=jV)mUcK=sKZ5HI3NB5wXgpZgl1V3Gy1Y!%RXOp7e}s79si zc9QNU8FG0|DxI=BD!|m1*Q2$xcTP|>U+kNxTI(hRd+QaIBzPrtqY}c=@-yl;tF%~xff0K z42I7^$DrM3A<6B&g(;18v~s@#BVhUq;vY;Sktv+3wYrtbaGuNlzSaRh<95)%$JvC6 zC__r429zF7x0w5mWwRV6v$g4mFe%vr?=&3e6>YHLypV}REJO{BHY`U?O%*tz^qQY& zxRyQM{~eB;RVNvZvEWAmpF}E>BW?DiZ1U;q_FNIVR&g46Xm|qG zFPu$#&zHfv?|bRkbq3pd;_y`mp*%N zH<+wA$0P?lNZyat!3 zF645o|L`^YV0h0b6jvH!hZY_uKEBdqQcM*){NpQ}+M~`Ue365!ciY+d3E^OwGK;>S zu1Vt5Ci8y%)+cUTZ$i|q6qE`}$ATqVte1ris(+kMq9BfU zT=)zZhT3pB9!-)ew}|7Iec&Hwopghpc%X&l=lq8| zE<4axSl$e9}4{y_$Q zrFB7IQ#MJm`NW=->tt3<8M82-Z~;zCoJE{>cfbd&M11(k1B;tG;7*M{)_3U>)?x=P zYvSShhGupfw{KtOdz@WyxS7|~wvbri9Ac-ygYE-e_&ZpVmK8q41;zV8U0Vc$yW5zL zY9a7?%Rl(bWffxFgTUmr2wDI265fdwKoyhMZ0byNda^AA96xh?<`HvvEv}CS&n`k- z=xO#x$yTQH^jUsQ^dkCYw*++PDbUsdN2t+!2a7*#qg_)Mvswp5=n0`y?A}efurkjV zCp;hIxVY)G_w)%iIv@j=2}rV^b+d3q-CcNM8w6c7od3Gv8CvJ`f}x!lC>Xnv)4{6b zeN8;+Nov4k*+^DVF`PDKs`J+D5+Pbs4#C>s4z^LO0Ig>vF&R@P6PLjZmKP+0L$73L zbFU7pnbQoGoq6zH(1iW#>_~*Vym&zdI>fy01tu&XXC*c-r`nsnAX~TyKSt-lp&8>$ z!=WX#U+xAC6?h8VNSih8_{2AxrN{O@=Q>y~3`uKtKfKO&CL0B};zBP;vSyTrMp?(e z`}}HJ?mNtGDfPm-Yxzjlah&A|<2Wzu7tFG(!zEM1(Ne>X7*{=o=0ks~8z#!aZbwnl zyWfeNKX;n^tZc=B0cTPnvW`3&vcU;nzzh0KrpV!r z9rHobs~K+hh|^7KP0%KRz$d|oIsO$Bc-5|>`N1hGA^I$zFy7Cb!URsbrKSQ#6iXe&GeiVJm zxSZkYH#|!XS?V$0s(P-<0a($sp1x)tV6WORNOBBPo3R9@)U}g6dDxIH5sv0RUOdih zW0ygEP9Ovr$f8J44Baus7S9oJ(qgcXj0LCS>Y|&lc2gQDYCBJROcs)-+jH1*&%?kB zc5&;<>*ze~FC(_mi(J3i1oKWjgze@d@WYThxBT)(*@=i(w;jPb5_QbF#dXl?-~y|3 z6PV|vgz`B@ZaHrgh6;{=l&UG-xipO|ik?bm2_=ETgy~e-^EwmnQUFFb77~-Fb8L9A zFQGG+60b9{M0#Na{<0ILzlIg*(k5>R?izrkq?e4$mpP;@`WBhz`WvtECZgE|2Kwac z+53|p^5YK6q2tX`#>$HG^S-yI*#&m29kw!Q8l1;?*M4{lS}b#GDQj~o7e`JcL93e+ z*pF4WAI{lYFAA6mT1w6B0g((S`n^ZX~&hOiOfN}W|%Jh8W)r(&|B?} zcy+xY`NU<<#TI*_nDjytF~ys6`kHck2q~iQa~VA1dac>fv&dn89r_X^$?;XPq-Ite zc30J+*w7-ZGwNXkR~}@}UC5=nOD{lB&_?J8lBNbR*?apPV zS{-WqREcDUN>QtVjqJd4ed6f2ld02JAzOlLpr}5VJy*RNR{yG{EvGuGk9=}s8=s_N zpZEvRH_ic1n-=`?Xc3N1x5ifcwN%>88NT`F;FoX9X`{LxjHtBWjc;>FORX-bc-{i0 zT7jQGwUnkPe`A7m`*E*gCYZFCLBO~j*(BNuC%3<5wrk$Pik6jFKjS)}oCqt_@Wmp` zWuRq32CaJTQ9_28e&gr1*C%HIKuBfEDD8q%i5r z)gqyb8bH+cKfdho7;L;cmp)URgbl1Dy})@Se?97AV+ZD=*}O6=JN6tDug@eNs$)Ez zM6O>k*@hC2Bg{I|redysIrFc2#BHmrcdaCB zO|qxM0(4Uc;fZ4!>EN8><_8BcX{e6zUiBETi#u;Qvk+D1f)#FsbfbhKOlrD{p<6!k zww1}#knOJUv&$9E`e&0{*^(I6bqAbhr?V&g&obwS93W_eGCQ#~4ZWW)CAlg0*%*5t zYC3ig&2l;K*<5i}Vq!15qpT0%zbnx6G>>h#SBr`|lH8tW6ml~(vH(((x?dMS|6zod@+QB8PZw1(HJwU8D(vLa1SI1b+%Ka{$k zgSHhBC@`fLXNQi%l~qdQi~1eR+LMOYiytsSx{X|iNrY5d&7c!h$CzES4@0`?JeVXC zkJba1(X+LYC)%|RzBs1<c!0O({~xQE}2!vw%G@aFnrm-^nz8)yFv1DXf8TH0X;=B8Q*l!XrHq`Z;VL zyRt}wX_|AIt~yo=x)VQxcjZMab_&8ZTPM(_>vzCHT#giWXpr8wuKeq(w_^9MdgMY! zy!XK~aQ$09?6c{@*o0Fw;d4EnUiyaBua}^QISxcvS3WaEbuYQ#aUWx|U!rQ?K|FLJ znG|#!2kqG>h~m9dq&IOgkzAxe4nI6f9)4KLOk8UNp@Gv#{N^Mah(Wk#U(Wp6JD(J7 zPlbeLI~sJdkNw@6N;6!hQT_u}ypmtaYxqF0+({p0%D8@=_iS?NIztx57Qw`7j>V#3 zMoT7{q1V)V7Avm!vZY1;v1(SqNJaimqFFOnWei{(JYZ;8M z?{wOeuRyH!hf&vW^XW$}ACYFfnSPDP!pKHhzTS8d*3UD=zFKLHXA#3mt9FBP{$z6V zh91l>`~hK&b5T6fh#II2;R1zLI;;^%mEWp@WT!icY)fSXoMh>rVq50+ElH?+rA2OV zOr`geC)0a|f^a!26!VA|6k{73dhI1V?rsLzZ(eL9=Rq3Ttj8!Vy9Cp}|6?ToxWLQJ zS+s9#Ib`z9i20y0IyZ1ygWEU2*6In3(!PnaW0rtGnFDG!>eK3XLd3t)oBqBOO-v(V zz<9+!jPG1R-p@JCN}W{Z|5ndtS|l{s`7>jf*AJqhNNqbzeK`vzzb&RW1Xa*Cu9z9y zJDE6ny=Q5TF%&KmBoXr@s3pdOpS>@YwHxCZ{n<#XOEpN;iGz?ap`Ov$aG0!dy}|JE z?y@Bii~adaNKD2ynCrU=P9L@+UX2V<@7~9`NrUmlcR@^Cl*Vg0y@%Z~o(}(R3S*{% zGv2!LgEuoomhE9oNsDI@5gX(+4K^x3QeLFRE6=h$N(4XQmQy=z>hd9 z1743A;;H3_URqmWLgQfwdVYne+i(ria}1%>b_<(kVL);sdLgLeAYJqKB5d?=$17J3 z(o<%&754dP(g9d)^khb5n_hr>8QzU_mxk=Qr~{OgKe%E0l44fi2)NJqZ6vyso}F z(Vec|E(;|^(scMntwqb})2P$^oa^PzhnjuX%!P%^iNnH;WI@sdv@2dr_*>n`>;n(j zP*D@miN4Mn$<;xz%XxODtITDj!rX_!0k*VBi%EBCm=eL_MT8damyr&hy^EM z%XTdyoH3L1QGWHc^(WxztBYVHwT{lK6eg}=a`ZrwD2bny#=ILYuRd_K1x833Gbkd$ zcP!w1FJ4>8Am^R(%TvSKOZW0S&&p8@N<-Q1$4rv?YIqZN7wwyN(#r1#sby0V{QICx z9CUV}3HQ9cDCf9$J`2ivMX+q^Gyto8%+^Q7R3)H^;d{&h zX0tz6$Uci|F3GISAtma&ZH(uYsm(Ka%g|zx^Du92AlcKpkft#eY*V%oP1&mtvoy5G z{qv9T<%AnhGV}pY_ugO?($>+fk3Pd*8P09GIf_+yp$RiRD^c0MsQSn45Abe+D&7nJ z3IWL(OkIgM?GCHQV>p@Etg@!6Pn7AD`(ya#h!Y$9%ZB^8oR@l$6RHH~&w%WTTD?)|ZMPWY}x!-4>ZJI)StQwd`wTFC{B#yW8ZX^D7?}ykXA^LD%2P{{Lqw)HR%;{AL zDAxENT-?dSE>C$9qY=Yq*r!0wBssQebuxIm58T+nyuqMF4;WDed6-Zm&QnsZ1yWy!*z<@92M5S9C4fLAX`(_q8tbY=wCVZD|OZfA?2ZuB#Fet8TLLc1XSlmP4H zj8yTAGy5az3)X-V_;N0y*e}w2#rYm!|EHc+P1c9zH4F|2iPMW;dr@yFmCQT*2=`x| zO`D$ia7+qK>Zq%Y6TVy0Q~$*9tnoN=a@?J~+cF7EHXH<##5-)Tgby4$$=yk>q~UVg z7_#*DG%}mJ`xL%RM!kAjXz{b*C05G91#x5I#20|kLh0&#26x z5jsU&m(I4$g@cZPu;+jxdD;6M=3Y1lp?R9*z>y2k<~yIPDn#!6ECBrv=Absm29*<$ zL<@OeSX}mvS-OV1AI#Xm9}8=PB|E>v2Fp?KdgX+lAG#9@A0zhjg{hPkSW8|XiXwse z-RSY`Jw$gc<%wT(KyjmQsQhy=T4l$v)X;?7wbr3#o|Vk>j0{lQI}IK@lqPAK_56Fg z5W+49k#hnIKz-~4ZHW1UmphH9hSX>H*sz0`y2QiD={am%l{jq>$%p(;vgn=d!Fzkw z2KbN7=9?KKH{E9kD9$m5?`A-p7$X9NEy0e+<`o~RzM!U^&|mzbBO(uE8P7# z6$1J4OvKDB{1&dmvia0ni2S31i;Iu)+amAq7F4XJ15y+5et`$E6>{YbM{u*dOLwcM zPrVFN)*h!uYrUw}n<_Nx@Bz2v)$re*Bg{s>OiZba!ylnjN$mDVtmI09DB4pmb;yjkeC=I-Ba$hT{qtyNA*%IukgS zjU$)4xJSsWQ+R6LPj-L17Hsg?gud1yiM<2j4kzn%rpbp)fe2aax!-33|d=1g126UJDcE&QskvhBU<1*c;uy#!| zT&s>J#8HZyroAA8s{^!9|n-nm*a3IU~Qn{iL74w-jb9c!+N(sf(( zsQZ=%u>BNEC$*-*=c~Kvdv2$si9A}6J%r27O+^nsc_bTsN&10)Jd~aeMejTKvR9-* zCP9~$FIFc_CH9#4REk%8M-d;~mg9DiVh~VgOrmcXlcD3n`1fcFqlp#x%*zSi&iR7r zS~k#gvxNB?ZbN1F_D41IUAFS09g^|qo5gq_nsbA<3Xz93S?rTDr>KAz$G@=TW`?_tFr|vxv{xYj z(m0l;b4CR8i)ye6)=q33_imlieUmqw_lO-C_y`}DWI(&ceyqOgL$`7HX;q;#lx92N z1v3Cr+S6caxD?sqQ3zf8-5JSsZhUbAANo}63|Un-mrnP*2j^deVVac?z2y{-iGu5R zd2=#oXZsFr2ky=KX+#r^hG*=|C!;1MQq#j&V8G)MG|I!{1`V zJ~5|d6y})xiyu1jK0hMtmot3E$WmFoDW-e*pY!3sQj zx0UX!hP4?v@!>ptYfHf4X70UcKAGiP$dZN&{~^yIg>N=hnag|1k)S8D>8s^+%-F0c zaQ$*HTrInchIJZ{xnwH&=C^=~^(euhZxwu8`U31i_=5-aghMlVU5#AOB27Y{Y)^gIph zxDo^pERtwcp)eIWIRjIE*T5<5QTBY90exEE!6-~z#j>+k;$oLJmeo@t7OE$hl$D$h zY5x!4uULWn!4|Y!y$^Oq-2mn9B{=+0o?ds<#s0iZGU{{{&w2jCk3Bgk*=a>nohfU+ ziQ|NX%{77-qcx)0sw91Vg?bu79a4lw* z-ozKSF?9EQdCbl@2fLqd;~P#c0Jm$L+kDk_pf>}F!Op{M&d>NM^CQXE9%=eA z_%{=A?m8R%)P&q?xsK*MQF=Z87|c)8gRjG9;9^ui*1A3550BWR%9B`fz~cPO|`^gkH~IyjmJ%sI}x zp$Oz+xBwZh<9ZfZ33zMTC8kUH6tq^&CR<_+=zDHH(je0bJoR62=(i!qY^rBN)@3rE zzTD^L09=Rf+dsa|h39zEsGsNB+s>AE42OZ{Px&N zH#^;coHL8acC+cAW4wqaZpp+2J;P9<`JO-G=8uxQ3itt!>)`wCToko0XS25CGy9V_ zP@nYQAf*z^6qzpvr|pAGX3jlunHhusY4^h+=c9Og!5YqG=tFHxqadcKikaRoL+TWuHTbvB~woTk#Za}{aa%gc5#)g zCrIWB6Z&c0}kgMdp$}&`z*@V3=CwOUPd=j(H zk%;Rr#$QKYL#uTP8s(m3cesSJ4-D;jwXGMJr4IX4aH5($ zsU4NTjj=OO>hw1Dt)?u=v@jzff2TlBq7pwc^)LINP><*?9tNezD717}p*=%#Fl}Bu zeEOlpImKtwFzpeXC$fZ`9gQI_`rfF4st?2_aGHn_Qw zyI(BkMH*}%qMVcCz=K(kd36_!J20O{C;PchP}GY<uCUcZlN2A^!1@O-VLx-(?clb~>%MIQ z@J{(NylHHPgXepgC_QVYz~71-S}scKBbPwZhCFoW<9IrYF2XF|3FPBK#oRIplrAY&z#O*A;eM#(SIOO3cG5VR3C6-)HMt zED6)4i{Ba3Gd8Vo^Y&DF<#Z+*eAXq*(==KaN7&x9DrV=b0nGSOfk&jI&}EfBTb#Td z0-tyBen!|c*`FN9@`=(!PD_uf1%H6Lwti@VlTa()jA8Ts;mvDhZ2ZVB)V#@p&YADH zX+Af14Ec%Aj}KyWMHTzs^%mv?!*#Oc5218y7g%i~;6G0ZBd1xg&l1vc_M1Uye_B98 zIsUsP5hJcsmf?}264u&g2Jx`IhlLB;p?+~P*$r{{c1VrZX2+xOb$9yu-&WXPpiKVb zp2si0FQm?g)QI!R4Pat%6yh4(ct^v{v6*c{>K754;{za| z(aSa#SrFOpXV83Dg#_O4Bel;0u-QqE=7w#;FwIn&bvzzHLm!vB3z7ZqznGMNDR@-! z2b$Fi)1s+TBw?=(Q{o-S=6##QPK{eZj0E4H=+-P4J?O!H;n+WCMi-(1$L@{?4Xm;* z!xO)@vMG*ln9oh4X#D3Lqfu^85^l?K-)CKX6{bpGgg21i*OTD>$#zgPY39yZiJ*E% zpI6%?NVQ2ID4r|^o4R?}wV7i=Y}?8cGohe=qZiV4XK=jE8g$b?iGgDmv8Qee6wI7T zYJEj$i)R_%=~Fgu_2(J1KckVA91g`530I-=?ht=8{u0UDeV$JJqsQpjij%D(o3P4J zn&hSD!H>t%^u4|TlP;&s%AZJOHXBj0FHe#NXHR1vM9b2Okr+(M&t?~IIgd{@zd&V_ z2fedt1Ks)1i1?lU$E*@8hNI7~vNA$Ea-ea*;_vrv_H^uS=KZG0^Z+xBUPxL*o^rei ze>Q?WwXqrVfAJx4QXaOb_uv!{k6Ya*(93y)Y}IlRlxTboZ{F|3mQ)RV zSooZu+SrW?jSn!z$_xrCePq&YXQK04CHgTr7#Pb=kS@?<|KtfH4lW~;EEbTc{z-Ij zTXl7VDaSW;R)Vapm&wi>_88vf%7?Dv4NbJ} zW8d^;Osm2OB)(jL4faN8X~*?Q2hzw6lTqxpYGLh{nA1Nd?t~ZJj}u(`P<^UAv0PTp zoTxqu;cHjmr{y~A2|+GvUm8hzKiCudUt@S;9cO@l7K&rrYVgL_g_u2QGBG}4Pxh@* zfM?MwNn}ViyXaX7IyqFZ^}HrV+FO$vrg9zRS~F5yAw}m|hBMB_b=ZFEIO(@vMykiZ zV^WYBS#DH?ck%?Onb23J-&zP7LTL~8+v6q z{IWPqmc)f)FxA3)*DOhf=`mb4`W|Kp6~PB%dGbk2on*iEfX9Ee34J@8u6SCDqf&)D zAyw}8#S^NmR}Hr3%$S$P5^%$FKHYO`8GpggEa<5&WG9M9P^qT{ATz)@E8kaJ{FY7R`JPBp)666Pm;{ip=ez*5Z$JT0VYk>}ZpZwT#g~%M z{&N79|CbFH#rKmPSH;MZ7Fl}pBcbP(MWdaWwCWKC*ie~b(JZlkJ>5SkoSdzaAMJQ-egHQ6# z((#uy*lA!$+WoKNrMnC1V}}_q%W<6f(|iS`)8+86#vmiON(l1|*OSXOtN1$K_YqA4 z3H*)CAm1%Y=C*TliV59pwAMqwktmq!eg>Yk=d#Hh8}_E-G{`@wKuXS6V{C>TGdj3~ zvG^R$lm+V0h}=n7|KcUYMm-1NJ^{LCY7U6M{sh(MZ}CkWROyEuuh469kj1~V6Ubbr zEI6`mCi$6L3axWEKXjig<`=D^JDtYakq~FFP0a=elK?tF>ljR`5#rbEUx9l*)?r4^ zF>)ku3a$2dz)Z2K!A;vwK+X37)`arVPK4tu3>BjGHf2(EUx=D3ZKo+OQozb$I<=gC znHc_T#Oiyx95?0?l{=@)3c1GN&!=O!fB#}KH2xZU)Soc6U+Nf1Ze9z!Lhz~nDe#SX z$`@JG%(|Ot)3&+>Br-X;MSLnTJTK1dx{?A}CEE1*`gHczsu$q)*9T>+&56fNj?dh? z6di0I;F`DBK)8py|21?nXLBTolCD1K-aH@uH|LXt&r|TQZV)ki;zrKd$ub@v-!sj} zH!`1{Ti{yOVeGoCO0?n>$%e}Nkae@ky0c9a&7~j`NtG zM{@zoCoosm{=g@R*`S?pn#tWe#>A=rUcs zF$WuDIhLeNA9u!I#_eS?QDgKSwwZ|GKG`<&bB2ZNCeH-043~!7kYRlOMvhkAGlvyq z10-}sG1HEHV$}M)F;jIPdHrTTd?Cts!`lm6rwdc@TPtX1pg%2@{tOihxmmW+0*I1c zir3}4;Z0G$>FfYu;_1@PPWkQvmjxV2jOPdw+y9iAnJ<9nkFCeGx1Axm>n6GTXC*4i z1OaLKhd$N`us0zO7pQM1vAZj=QkY{dJT@Z#AOUAi+lo(ig_1;r)u@yxh9XLnX;kD4 z`u=7jxQs|+LedZ(TAWJDw#g84MvVT^j-Vfp??A`kXh!AY2ROXV2@WTkQs48ARD3v| zePGqaERm1Fq0(G*?B4;`KgF@Le=E_bozhgfk@FKAkt1RG$FM2!3;T9*7|G4%vadHY zX}37Xe6U-C5%R+1$m-|JAN7S0IFI{&X+FXYRkrY3;RfI4Xdo5#62Lk0_|SP*jDC~7 zgq}O3S+Dds95*l}>7O;|Th{^5vr5303S|#uGw;?6yuvbk)PeLaMjZba+b+tAyo0v>YDMAi=5-k0o`n z$$dib@nk*|bMrBFzwQRBGqJEZt`x?W-z3|nixHa-<(!A$6y8c~U=LrLLc+ce;(VzE z#IZ6HYpzE_MXeI~WOED@rQYGiBLXD%njD$=N`O?xW|7RJn)GDX3uf%!Zstz!0lHz| zei;6Bmu z!QZcr*=Tlx+|jAU^_TAw{{Ruj+tnGHem%mPEe}v5a5A~@az5Sf=uFG)_W)}&9S{8C zKHqo-qxMP|NRBGCE-iT*1iVlSxZLhZ!Img=60ew_Ov6{6!5q>4+aq z%veaAjiR|uLksFgs1VVZ0+b56jG$dFe)m zNEfrx+>{z_`-rCxy#doh;WR5R3gcP~>C?et@BBz>kt+zq z-h>-H0_0$oGW6}YrWQ(9VO?x5#6L2mDtEa(RK);>bD5jCOfB|R&oElAvBb}jVaV!e zliCBqA@-<}OAG9st4na`ezZ zEj)RrNS?fm=T)tuMCFWee52`^ElTrJcRxa_xxLO(dH|-MZsm%GmsUSUf@CO#3e1iL; zw=v%^1Z2-2rE?x#8B6@L&0^Cq3-TvT1~4yu?HXANnuuQriCK8?g#7vk^Kr8u!ej+C1AfxPSbhC~EdN_$GP|Yac|YRr@mdaYN}?Yd!}a3TK)9X-3u1DJMr`I@CZyk|Me68 z-wlt#f7-1+fj+x7`x|cFwPl-MsLz(r-Mf55CdVZHGaek|7Z~cZYvVSb&E6{iO`l@o}q-5mPA){y$oJUHm<7qBsSmyfUCzJs>Y z{%7f(yM01J{elAjYm@)Cf&f2nZ-1YIwmSc_f}r6465lwvi(R4rr9|>S)8U|3(7ylO zeb83x|CbJ*K=1$3ApU=8nCv`gJN18-_x9Q1=l$QDLC9|3|8)k6|DE^_pD;r&zs(`M zlY=te{v-eYXzpZ)G_Q!sYu;>bXJ2~cY%k6sqyk@k1^Wp2}pK#V-6$x7Z4m>-4 z;_f^4g!_FyIbvK&Zq;~VT;DO^6$v4GUQ5vNyMELu$N^tJJ3<7`HnSYRE#OErFh(K~ z4)C4;?YV0B_X7`&owJaee^H5YwEuyr#ByS^DGOJO-DaoXx&iUdho}u!G9~k*iE45- ztX`6V%03qGM=+RS4EIo_;7>5DCrDH07ox0s4RrJ;U}8E$?-l2RvbPvrG8#{R@R-tK zv6D=@iX(U6)LDGq{RN%mKVnNkB{bbFAjXAOY@pQ;FHiJ<^2HXCBXPoIZ4S33+dA7nShchahWchG#f z7+j9}LU5uuOz`OtpF(Zo+W!%^Xn4>_z2_X)11~_<))&0r7qR2PCS+#hV)%0U5+=vW z(G`3d9G!>z*iX-#F!e?+ICyq4pXL=a8`|$e?GJZy^K>45uaY5|d6w{WdMnPfj%U|y z_yMz*|3LeSbMQ*l2cD`nU{fIYt)3Pe>dYf<<(T&S(6^wYDRf9cL&#i-?KLPB6)BjN7+f}^4sDz(JTerWiLTh~Ch<1JO}#jSN% z`!92H-2&jL8v@4g0)8n!jvk2xO#j}$oC>QGIQwWmo3+3L5_gGH)po+Y;9yMBk(bW6 zQ;MRJ;&l7!Ii!yFEw0lofy0NEkU4M7=?~YJFnZCC_L`@nYTp(NOp_y5ujGQ}93?dS zsR6<3s!^gNnT`9#ke&0=nLGWDa7HYB%ZEMX8w&ZgpE1Q1+NVl zd+ZrLZdZdx(rV-lW-;G_KQao>hS8 zm+Wb3tTa90)QRH@M?mw^IK-Iyv&TA>pj-Mi)1kW!8hgb^lZ87zn=L~Va%a(JHK}+# z+XDZH8PZEhvP8Q$2aiAR1uyehl)E&Sm|E*nO{cpc<^Avu`^qvAY&8L`4!E16RUhQH0zz7h$Zb9#8Jj zAqAHOX=ua+JfcwyGfs*SgPwjy?S3V@bJKTb7#&S=dyA^Xy@@XV?dTEEK`GR#kL$eAXr)8EI+Dhko3Cc|K-QG=)2 z1UXuvY2@0XIPzR}7TEsEWhyh8X-`A~jP1S$ZrkeFMe9vSd%h+Zf0#i>Ok3biN&;3q zx&d3-hnn8xPA9r)ytFzq7CD2lq;^<^z7Dph2m0UQ*NyMt_~f@_@7M*V_j@>}_^m13 z>l1}NsDl&GfIium;o|%I%mn0dZG(m|-nxVFBdcMw;{;sMQ6Np5t#KgTo*42AQk-JS zF=4%_VM++&9a98mX(4o7+*$T+?mu>AR4NwBnv=qwXzKn=0FP+J69XAj`lVkI&xn3% zI_J}mjR|+4KAN9?RM8=aCa!SS_RptVwj|&HTS*U1NyI5h4%osTht}DJ=y`o5;muK` zH@v%XP=$-rc=TwhTpFhAUO}1?+L)p5OQ=+I2iq?{`3^nxBm1{K;yf#Hg1Uz}9EORc zGS+ceXZHh-6qMmi!#rsF`W2eW1xb~1C+hm|#j)U8rcX(OJ+fkyBlL-v7KOiqWQ8B_ zWo9HkUU?7tH(BA)yMnMp-V)w)ZO7g^aoTL?fcoZhncgk!__DK*?v8oF{!41t3@6yW8y45fk~RJOlYbVt?xGmW!0I)<$*8N z{+t7MlULH+`pa!fBKU~X*J)&m@eqdAu7K=+16Z_l5WIe_qFx&URRr3jMIz58a#5{=7oR9n3W>J3G<@8FEHFGjQfc3L@j;RKhQ2g~R z?(Ib-v@0x%^@exsJ=0N6z~N!$xUn%w+_IPMsDw%+-2dG;9 z3fh;Z1%C@Y8PpktOD2~wO3V-Uz0Jm?v2o6$;8^MywUga2WJ6Zx7s9Gr=`j9f8&h$? zh3u9xWj>bmV*JEB%B#Qxa)Wpe_p6yx|~_2DElwl*C0rDc^^mV++p^{@^Q@iJH$G31*vn~5*&Fc zjbym4a$E-j8mi%%M)_1rkl(vOAb4`a~c>StDc;tU%q9L+>nXFQ%gG_v6=0Gv5?^x#cwTWlW4_@vMc#>u1By7mJC;9(@w$ zcn#c3Dfc<+kHJ#D`17>_Wn(yQYzHN1^uZ}65q32CV3nML`1%CW=YEs`P~N;eA;zTS=G&yTYloh36twu{46 zINky>*015l`xoG2)DOE~RD(zl3o~2zVcoSznpmpB@byYi{=dtKa)&LHDLY`ur%@*S zDla^Hmr2IlR6s@4hvrL_k-7D9Ff^e`FMQV_dmi6`RjFB2a+M$XbGnk@tfz?Ly@+0m z7~>Ub*!Do1)QY`^T2D!4jn`GEZ!MthaSSmDPlGu>canCyRU~xR3%H?dNMGwuCDqqs zz`(x>?{{6{UXzj{({IKR%eS(mF2@(5>wGbFbt%g5or0PhnrJLrjXU{Y!N8dqu7JsN zn|q={80q*D?j)b$ejl&HDDzLaf8GwT8|j1e`-Ry1+L1(msDj_eE`v$-Y_e2+2j_1? z94ld?jS4n1;Q52+XxW+1J_>ru;@^v`%cV*bIOIlB^1m_DhHoG-nnCU?t|zYUYw5ZN zOUbcK*=Q&yL3!h6L8j?qsz31yTxW=anZgUes(aWyEr$($_y!jX)(;Tv+`xwl*`4%)gC!Hi2DDov3c}2Ti0=l zBpf`+R4o-HJ$q#6?&3}4=k23}o_0p-8f)CB@f`fKtHAo<0X(}r3Ug!RSmxPc*y?tG zx&3T5YrFm{9`iOP?OheP>4Br&jaw~f;q0_SHQNKe)uUdiBm#aSc?OiIQKvf)}*ze_veQeu3HQ+m~N#*D6g8c!K z^kHH#h}+FYFgXMXLi^am7bVbstvcD(Zw(@!%Q!zS9U$if4%2myb`!ay(zM{%PN?6= zhbpWVeSTPp1S|Q#qi;9aMSdf&>sK1)>87!kL+jaxLWMMOh75X3ZlH7SZvuRGo-BLd zNIIugTFY9$K>K?xME8gtyGTKeS{O^wnz0*}J%pbW|Mzh1`ppUbhS$=v`DO?d^@iOwb zLjt<823e8WTx?Z;j28{M*$=nEAlUW{vnH_JJR%Cguw0i&6n;aa^VOK?Jn0jRGK5jf zr+Uw`;eB>Jwjbmn4Wj&n7*X=``*EzX&;jG311LY%OkV%BB(V{d$T6J9-Mr!xCfvxt zVi5&2=?q7qiruuaWCv(#Y$IzoJcq4U7EzlsrtF%)okZ%z1bbBJ3-%CU5;bQF`QtmE zriHC!ZXU8EZHZ$Ld3zrBphy^e>WP9Ft2&&yTac{XArG9$ImF(#lDd}H!WZjlw7&NS z?h_HD1+AHI@y9~atyzq%lelTs-ZmU|(xioC1?_CJgB2Q|(CW|H#*#JlY~{32QqtNF zq51jnqv9!c220uad&a}ArB~ReA)ncT_dl4tZDowlktEEYB~P5!m^1G?Ov!9JXR=4= zG*~`#qI2h7fLFzGaCNINS+8-13@+Y5%H#U*UC%mpskIH++xD2*B{T&ms`x=g+5~R< z?tzh|T@b6^%MHH%5!V~1a%#4I$K!(+v9d&p2#T*E7rQ1c`IN)p8g!oYbtTc#CUvN2 zQY3qBMc@oKTYAQM4CiF&vpGMypvmbchBURZvB%rlXY?NGiNvFQUmEO^GN5`vwQzoG z3^iZ-mYMN$I-W4i!F?Ch;ky4#SZW)KO8Hvs)edc1T6q{HO6Gy=vRc@(a60K5{{edw z-+22J~Thw(ephAJ=ZXt<98-M)VlJ5~&3`19}sjC?1J6?Sj%lB5yD8=ZiKNeE|BB5;S@B5IgzL(AP) zAWuvVSSC#3x%sESGCYSHJYAc3CHT_S9ZNt zHuKDGg21Uc5Yzn~I<~u#A02^II9QyV%3MzR?73wH!4_pAv%m>W& zByA@1%S=+S^gN2{d*Ced_b|;-hjkn}PSuk&*pe6ldcf=)vg5zezwZz|@A!ZoR-a1c zYcug^LpYuBng@Ql)j-zfYPO+kK2=EH#+-2FrSUx+f_Ea=e?#jqZq5NL$ZKWu>yO}~ zvs2NnYd=;9P9bMzUWKh@JHhK=8^qllK$(4tu;T18vUAO8y64wnob&i4Zv63x*|;~5 z@^X(ckMH`TT{0iBv;2XMhs4Mw{|ZQ$YECztJqRs-73l7p&+w?24JMYzkgyYpOyXa8 zD(7elM_!o_sb!~7;$0y)&a|N4x$nX7(ECYlO`G&}Pb00_`)FwT1E%?9UsI_}DSkfR z52bP!(ER0mICpmjnH6L}-HWWL`I6swaMJ+u;?`SE#Ct>dxph9hH&KEa;w8l5N(lB< zyE5wJH}k&C3oa%oLz`k3QxMh(-ZHWz|41lxEt^63`?M&3WiBkN+yS`S1QP!e+_*rM z@)|m`HE-6_Hrv0a!BgJ@pAVmyfRwKS>JEQybUxb(>A|FfgulaB%}pP z&l(e@`&{k|oK$twkI9;+df#d#W+#Fm`k_!;!{&kaGJuns1gR^A5~`+itl{ZMQC&;%egFRCeh-0S#ooA&AsT;jG*Uzjc&p6kZ`Ey^fhCEj>X2ggVzdMC- zllCZLmM9r@c!DdQRl(bDW$0sa9v-$^!Wlaq9M9ZKZD&)Pj4nHD61oU}|FTHQkynhv zi)+mD>Zx#wPXNE0^?{4waC zU`7lsdMHdxZI-dRuOEQJkN5CT!;Y$qoq%-LD5mt;GICmLG5dQ{6nR7yV9mI}q@@<5 z?XMRxgJzCIPW2Z2a84yYw~PS0E8#)RIo!EcmlSʧMg_*GsFauRiH<5zhy@!=Z$ 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 S+2~sq0`(_Xk^L8T;{O0ueccfN literal 0 HcmV?d00001 diff --git a/fixtures/qwen3_5_0_8b_synthetic_pointer_head_with_wrong_hidden_size.gguf b/fixtures/qwen3_5_0_8b_synthetic_pointer_head_with_wrong_hidden_size.gguf new file mode 100644 index 0000000000000000000000000000000000000000..5a518c3cc68b90c2aac0cfcb4fa67dfe74aed4bb GIT binary patch literal 66368 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 cfg.balancer.inferenceMode == "text-generation"; + message = "services.paddler.balancer.openaiCompatAddr is only served in text-generation mode."; + } + { + assertion = cfg.balancer.typesafeCompatAddr != null -> cfg.balancer.inferenceMode == "decision"; + message = "services.paddler.balancer.typesafeCompatAddr is only served in decision mode."; + } + ]; + systemd.services.paddler-balancer = { description = "Paddler balancer"; after = [ "network-online.target" ]; diff --git a/jarmuz-fmt.mjs b/jarmuz-fmt.mjs index 2bbd3ee81..825f346ac 100755 --- a/jarmuz-fmt.mjs +++ b/jarmuz-fmt.mjs @@ -8,9 +8,15 @@ jarmuz({ watch: [ "jarmuz", "paddler_agent", + "paddler_agent_decision", + "paddler_agent_embeddings", + "paddler_agent_pointer_head", + "paddler_agent_runner", + "paddler_agent_runtime", "paddler_agent_status", + "paddler_agent_text_generation", "paddler_balancer", - "paddler_bootstrap", + "paddler_balancer_runner", "paddler_cache_dir", "paddler_cli", "paddler_cli_tests", @@ -25,12 +31,15 @@ jarmuz({ "paddler_messaging", "paddler_model_source", "paddler_openai_response_format_validator", + "paddler_openai_translation", "paddler_opencode_tests", "paddler_request_registry", + "paddler_service_thread", "paddler_state_database", "paddler_test_cluster_harness", "paddler_tests", "paddler_tool_call_validator", + "paddler_typesafe_translation", "resources", ], }).decide(function ({ matches, schedule }) { diff --git a/jarmuz/run-website.mjs b/jarmuz/run-website.mjs index 43cb0bb81..08d72313f 100644 --- a/jarmuz/run-website.mjs +++ b/jarmuz/run-website.mjs @@ -8,9 +8,15 @@ export function run({ development, once = false, rustJobs }) { pipeline: ["stylelint", "tcm", "tsc", "eslint", esbuildJob, ...rustJobs], watch: [ "paddler_agent", + "paddler_agent_decision", + "paddler_agent_embeddings", + "paddler_agent_pointer_head", + "paddler_agent_runner", + "paddler_agent_runtime", "paddler_agent_status", + "paddler_agent_text_generation", "paddler_balancer", - "paddler_bootstrap", + "paddler_balancer_runner", "paddler_cache_dir", "paddler_cli", "paddler_client", @@ -20,9 +26,12 @@ export function run({ development, once = false, rustJobs }) { "paddler_inference_parameters", "paddler_messaging", "paddler_model_source", + "paddler_openai_translation", "paddler_request_registry", + "paddler_service_thread", "paddler_state_database", "paddler_tool_call_validator", + "paddler_typesafe_translation", "resources", ], }).decide(function ({ matches, schedule }) { diff --git a/jarmuz/worker-paddler.mjs b/jarmuz/worker-paddler.mjs index f7a9f3484..1864ace43 100644 --- a/jarmuz/worker-paddler.mjs +++ b/jarmuz/worker-paddler.mjs @@ -17,7 +17,7 @@ spawner(async function ({ buildId, command }) { const results = await Promise.all([ command(` - target/debug/paddler balancer + target/debug/paddler balancer text-generation --compat-openai-addr 127.0.0.1:8063 --inference-addr 127.0.0.1:8061 --inference-item-timeout 30000 diff --git a/package-lock.json b/package-lock.json index 6cfd1c119..6c3135447 100644 --- a/package-lock.json +++ b/package-lock.json @@ -6843,7 +6843,7 @@ }, "paddler_client_javascript": { "name": "@intentee/paddler-client", - "version": "4.0.1", + "version": "5.0.0", "license": "Apache-2.0", "dependencies": { "nanoid": "5.1.16" diff --git a/paddler_agent/Cargo.toml b/paddler_agent/Cargo.toml index d9c84ab8f..c1575e1ad 100644 --- a/paddler_agent/Cargo.toml +++ b/paddler_agent/Cargo.toml @@ -10,55 +10,34 @@ version.workspace = true [dependencies] anyhow = { workspace = true } -async-stream = { workspace = true } async-trait = { workspace = true } -bytes = { workspace = true } -dashmap = { workspace = true } -encoding_rs = { workspace = true } futures = { workspace = true } futures-util = { workspace = true } -hf-hub = { workspace = true } -llama-cpp-bindings = { workspace = true } -llama-cpp-bindings-sys = { workspace = true } -llama-cpp-bindings-types = { workspace = true } log = { workspace = true } -minijinja = { workspace = true } -minijinja-contrib = { workspace = true } -nanoid = { workspace = true } -paddler_cache_dir = { workspace = true } -paddler_download_manager = { workspace = true } -paddler_image_decoder = { workspace = true } -paddler_inference_parameters = { workspace = true } -paddler_agent_status = { workspace = true } -paddler_messaging = { workspace = true } -paddler_model_source = { workspace = true } -paddler_request_registry = { workspace = true } -paddler_tool_call_validator = { workspace = true } -parking_lot = { workspace = true } -rand = { workspace = true } -reqwest = { workspace = true } -serde = { workspace = true } serde_json = { workspace = true } -shellexpand = { workspace = true } thiserror = { workspace = true } tokio = { workspace = true } -tokio-stream = { workspace = true } tokio-tungstenite = { workspace = true } tokio-util = { workspace = true } trzcina = { workspace = true } -url = { workspace = true } +paddler_agent_decision = { workspace = true } +paddler_agent_embeddings = { workspace = true } +paddler_agent_runtime = { workspace = true } +paddler_agent_status = { workspace = true } +paddler_agent_text_generation = { workspace = true } +paddler_inference_parameters = { workspace = true } +paddler_messaging = { workspace = true } +paddler_model_source = { workspace = true } +paddler_request_registry = { workspace = true } [features] default = [] -cuda = ["llama-cpp-bindings/cuda"] -metal = ["llama-cpp-bindings/metal"] -vulkan = ["llama-cpp-bindings/vulkan"] +cuda = ["paddler_agent_decision/cuda", "paddler_agent_embeddings/cuda", "paddler_agent_runtime/cuda", "paddler_agent_text_generation/cuda"] +metal = ["paddler_agent_decision/metal", "paddler_agent_embeddings/metal", "paddler_agent_runtime/metal", "paddler_agent_text_generation/metal"] +vulkan = ["paddler_agent_decision/vulkan", "paddler_agent_embeddings/vulkan", "paddler_agent_runtime/vulkan", "paddler_agent_text_generation/vulkan"] [dev-dependencies] -indoc = { workspace = true } -paddler_local_http_fixture = { workspace = true } tempfile = { workspace = true } -tokio-test = { workspace = true } [lints] workspace = true diff --git a/paddler_agent/src/agent_applicable_model.rs b/paddler_agent/src/agent_applicable_model.rs deleted file mode 100644 index 80f0fbd5e..000000000 --- a/paddler_agent/src/agent_applicable_model.rs +++ /dev/null @@ -1,11 +0,0 @@ -use std::path::PathBuf; - -#[derive(Clone, Debug, Default, Eq, PartialEq)] -pub enum AgentApplicableModel { - #[default] - NotConfigured, - Resolved { - model_path: PathBuf, - multimodal_projection_path: Option, - }, -} diff --git a/paddler_agent/src/agent_applicable_state.rs b/paddler_agent/src/agent_applicable_state.rs index 43a41548d..4f308c355 100644 --- a/paddler_agent/src/agent_applicable_state.rs +++ b/paddler_agent/src/agent_applicable_state.rs @@ -1,11 +1,55 @@ -use paddler_inference_parameters::inference_parameters::InferenceParameters; -use paddler_messaging::chat_template::ChatTemplate; +use std::path::PathBuf; -use crate::agent_applicable_model::AgentApplicableModel; +use paddler_agent_text_generation::text_generation_settings::TextGenerationSettings; +use paddler_inference_parameters::embedding_parameters::EmbeddingParameters; +use paddler_inference_parameters::model_runtime_parameters::ModelRuntimeParameters; +use paddler_messaging::chat_template::ChatTemplate; +use paddler_messaging::chat_template_source::ChatTemplateSource; #[derive(Clone, Debug, Default, PartialEq)] -pub struct AgentApplicableState { - pub chat_template_override: Option, - pub inference_parameters: InferenceParameters, - pub model: AgentApplicableModel, +pub enum AgentApplicableState { + Decision { + model_path: PathBuf, + model_runtime_parameters: ModelRuntimeParameters, + pointer_head_path: PathBuf, + }, + Embeddings { + embedding_parameters: EmbeddingParameters, + model_path: PathBuf, + model_runtime_parameters: ModelRuntimeParameters, + }, + #[default] + NotConfigured, + TextGeneration { + model_path: PathBuf, + model_runtime_parameters: ModelRuntimeParameters, + text_generation_settings: TextGenerationSettings, + }, + TextGenerationWithoutModel { + chat_template_source: ChatTemplateSource, + }, +} + +impl AgentApplicableState { + #[must_use] + pub fn chat_template_override(&self) -> Option { + match self { + Self::TextGeneration { + text_generation_settings: + TextGenerationSettings { + chat_template_source: ChatTemplateSource::Override(chat_template), + .. + }, + .. + } + | Self::TextGenerationWithoutModel { + chat_template_source: ChatTemplateSource::Override(chat_template), + } => Some(chat_template.clone()), + Self::Decision { .. } + | Self::Embeddings { .. } + | Self::NotConfigured + | Self::TextGeneration { .. } + | Self::TextGenerationWithoutModel { .. } => None, + } + } } diff --git a/paddler_agent/src/agent_applicable_state_holder.rs b/paddler_agent/src/agent_applicable_state_holder.rs index 8f8f96f6b..4632dccf8 100644 --- a/paddler_agent/src/agent_applicable_state_holder.rs +++ b/paddler_agent/src/agent_applicable_state_holder.rs @@ -32,23 +32,20 @@ impl AgentApplicableStateHolder { mod tests { use std::path::PathBuf; - use paddler_inference_parameters::inference_parameters::InferenceParameters; + use paddler_inference_parameters::embedding_parameters::EmbeddingParameters; + use paddler_inference_parameters::model_runtime_parameters::ModelRuntimeParameters; use super::AgentApplicableStateHolder; - use crate::agent_applicable_model::AgentApplicableModel; use crate::agent_applicable_state::AgentApplicableState; #[test] fn a_new_subscriber_sees_the_state_applied_before_it_subscribed() { let holder = AgentApplicableStateHolder::default(); - let applied_state = AgentApplicableState { - chat_template_override: None, - inference_parameters: InferenceParameters::default(), - model: AgentApplicableModel::Resolved { - model_path: PathBuf::from("model.gguf"), - multimodal_projection_path: None, - }, + let applied_state = AgentApplicableState::Embeddings { + embedding_parameters: EmbeddingParameters::default(), + model_path: PathBuf::from("model.gguf"), + model_runtime_parameters: ModelRuntimeParameters::default(), }; holder.set_agent_applicable_state(applied_state.clone()); diff --git a/paddler_agent/src/agent_desired_state_converter.rs b/paddler_agent/src/agent_desired_state_converter.rs index 9908bb5a4..a88d7a598 100644 --- a/paddler_agent/src/agent_desired_state_converter.rs +++ b/paddler_agent/src/agent_desired_state_converter.rs @@ -5,15 +5,20 @@ use std::sync::Arc; use tokio_util::sync::CancellationToken; use paddler_agent_status::slot_aggregated_status::SlotAggregatedStatus; +use paddler_agent_text_generation::multimodal_projection::MultimodalProjection; +use paddler_agent_text_generation::text_generation_settings::TextGenerationSettings; use paddler_messaging::agent_desired_model::AgentDesiredModel; use paddler_messaging::agent_desired_state::AgentDesiredState; +use paddler_messaging::agent_inference_settings::AgentInferenceSettings; use paddler_messaging::agent_issue::AgentIssue; use paddler_messaging::agent_issue_params::model_path::ModelPath; +use paddler_messaging::agent_text_generation_settings::AgentTextGenerationSettings; +use paddler_messaging::decision_settings::DecisionSettings; +use paddler_messaging::multimodal_settings::MultimodalSettings; use paddler_model_source::desired_model_resolution::DesiredModelResolution; use paddler_model_source::model_source_error::ModelSourceError; use paddler_model_source::resolve_desired_model::resolve_desired_model; -use crate::agent_applicable_model::AgentApplicableModel; use crate::agent_applicable_state::AgentApplicableState; use crate::agent_desired_state_conversion::AgentDesiredStateConversion; @@ -26,10 +31,9 @@ impl AgentDesiredStateConverter { pub async fn convert( &self, AgentDesiredState { - chat_template_override, - inference_parameters, + inference_settings, model, - multimodal_projection, + model_runtime_parameters, }: &AgentDesiredState, ) -> Result { let ControlFlow::Continue(model_path) = self @@ -38,28 +42,72 @@ impl AgentDesiredStateConverter { else { return Ok(AgentDesiredStateConversion::Cancelled); }; - let ControlFlow::Continue(multimodal_projection_path) = self - .resolve_model_file( - multimodal_projection, - AgentIssue::MultimodalProjectionCannotBeLoaded, - ) - .await? - else { - return Ok(AgentDesiredStateConversion::Cancelled); - }; - Ok(AgentDesiredStateConversion::Converted( - AgentApplicableState { - chat_template_override: chat_template_override.clone(), - inference_parameters: inference_parameters.clone(), - model: model_path.map_or(AgentApplicableModel::NotConfigured, |model_path| { - AgentApplicableModel::Resolved { + let applicable_state = match inference_settings { + AgentInferenceSettings::Decision(DecisionSettings { pointer_head }) => { + let ControlFlow::Continue(pointer_head_path) = self + .resolve_model_file(pointer_head, AgentIssue::PointerHeadCannotBeLoaded) + .await? + else { + return Ok(AgentDesiredStateConversion::Cancelled); + }; + + match (model_path, pointer_head_path) { + (Some(model_path), Some(pointer_head_path)) => AgentApplicableState::Decision { + model_path, + model_runtime_parameters: model_runtime_parameters.clone(), + pointer_head_path, + }, + _ => AgentApplicableState::NotConfigured, + } + } + AgentInferenceSettings::Embeddings(embedding_parameters) => { + model_path.map_or(AgentApplicableState::NotConfigured, |model_path| { + AgentApplicableState::Embeddings { + embedding_parameters: embedding_parameters.clone(), model_path, - multimodal_projection_path, + model_runtime_parameters: model_runtime_parameters.clone(), } - }), - }, - )) + }) + } + AgentInferenceSettings::TextGeneration(AgentTextGenerationSettings { + chat_template_source, + multimodal: + MultimodalSettings { + image_resize_to_fit, + projection, + }, + sampling_parameters, + }) => { + let ControlFlow::Continue(multimodal_projection_path) = self + .resolve_model_file(projection, AgentIssue::MultimodalProjectionCannotBeLoaded) + .await? + else { + return Ok(AgentDesiredStateConversion::Cancelled); + }; + + model_path.map_or_else( + || AgentApplicableState::TextGenerationWithoutModel { + chat_template_source: chat_template_source.clone(), + }, + |model_path| AgentApplicableState::TextGeneration { + model_path, + model_runtime_parameters: model_runtime_parameters.clone(), + text_generation_settings: TextGenerationSettings { + chat_template_source: chat_template_source.clone(), + image_resize_to_fit: *image_resize_to_fit, + multimodal_projection: multimodal_projection_path.map_or( + MultimodalProjection::NotConfigured, + MultimodalProjection::File, + ), + sampling_parameters: sampling_parameters.clone(), + }, + }, + ) + } + }; + + Ok(AgentDesiredStateConversion::Converted(applicable_state)) } async fn resolve_model_file( @@ -93,19 +141,29 @@ mod tests { use std::path::PathBuf; use std::sync::Arc; + use tempfile::NamedTempFile; use tempfile::TempDir; use tempfile::tempdir; use tokio_util::sync::CancellationToken; use paddler_agent_status::slot_aggregated_status::SlotAggregatedStatus; - use paddler_inference_parameters::inference_parameters::InferenceParameters; + use paddler_agent_text_generation::multimodal_projection::MultimodalProjection; + use paddler_inference_parameters::embedding_parameters::EmbeddingParameters; + use paddler_inference_parameters::model_runtime_parameters::ModelRuntimeParameters; use paddler_messaging::agent_desired_model::AgentDesiredModel; use paddler_messaging::agent_desired_state::AgentDesiredState; + use paddler_messaging::agent_inference_settings::AgentInferenceSettings; use paddler_messaging::agent_issue::AgentIssue; use paddler_messaging::agent_issue_params::model_path::ModelPath; + use paddler_messaging::agent_text_generation_settings::AgentTextGenerationSettings; + use paddler_messaging::chat_template::ChatTemplate; + use paddler_messaging::chat_template_source::ChatTemplateSource; + use paddler_messaging::decision_settings::DecisionSettings; use paddler_messaging::huggingface_model_reference::HuggingFaceModelReference; + use paddler_messaging::multimodal_settings::MultimodalSettings; use paddler_model_source::model_source_error::ModelSourceError; + use crate::agent_applicable_state::AgentApplicableState; use crate::agent_desired_state_conversion::AgentDesiredStateConversion; use crate::agent_desired_state_converter::AgentDesiredStateConverter; @@ -133,10 +191,17 @@ mod tests { multimodal_projection: AgentDesiredModel, ) -> AgentDesiredState { AgentDesiredState { - chat_template_override: None, - inference_parameters: InferenceParameters::default(), + inference_settings: AgentInferenceSettings::TextGeneration( + AgentTextGenerationSettings { + multimodal: MultimodalSettings { + projection: multimodal_projection, + ..MultimodalSettings::default() + }, + ..AgentTextGenerationSettings::default() + }, + ), model, - multimodal_projection, + model_runtime_parameters: ModelRuntimeParameters::default(), } } @@ -238,4 +303,144 @@ mod tests { discriminant(&conversion) == discriminant(&AgentDesiredStateConversion::Cancelled) })); } + + fn local_model(model_file: &NamedTempFile) -> AgentDesiredModel { + AgentDesiredModel::LocalToAgent(model_file.path().display().to_string()) + } + + async fn converted(desired: &AgentDesiredState) -> AgentApplicableState { + let outcome = AgentDesiredStateConverter { + cancellation_token: CancellationToken::new(), + slot_aggregated_status: fresh_status(), + } + .convert(desired) + .await; + + match outcome { + Ok(AgentDesiredStateConversion::Converted(applicable_state)) => applicable_state, + Ok(AgentDesiredStateConversion::Cancelled) => { + panic!("the conversion must not be cancelled") + } + Err(conversion_error) => panic!("the conversion must succeed: {conversion_error}"), + } + } + + #[tokio::test] + async fn an_embeddings_state_without_a_model_is_not_configured() { + assert_eq!( + converted(&AgentDesiredState { + inference_settings: AgentInferenceSettings::Embeddings( + EmbeddingParameters::default() + ), + model: AgentDesiredModel::None, + model_runtime_parameters: ModelRuntimeParameters::default(), + }) + .await, + AgentApplicableState::NotConfigured + ); + } + + #[tokio::test] + async fn an_embeddings_state_carries_its_resolved_model() { + let model_file = NamedTempFile::new().unwrap(); + + assert_eq!( + converted(&AgentDesiredState { + inference_settings: AgentInferenceSettings::Embeddings( + EmbeddingParameters::default() + ), + model: local_model(&model_file), + model_runtime_parameters: ModelRuntimeParameters::default(), + }) + .await, + AgentApplicableState::Embeddings { + embedding_parameters: EmbeddingParameters::default(), + model_path: model_file.path().to_path_buf(), + model_runtime_parameters: ModelRuntimeParameters::default(), + } + ); + } + + #[tokio::test] + async fn a_text_generation_state_carries_its_resolved_multimodal_projection() { + let model_file = NamedTempFile::new().unwrap(); + let projection_file = NamedTempFile::new().unwrap(); + + let applicable_state = converted(&desired_state( + local_model(&model_file), + local_model(&projection_file), + )) + .await; + + assert!(matches!( + applicable_state, + AgentApplicableState::TextGeneration { + model_path, + text_generation_settings, + .. + } if model_path == model_file.path() + && text_generation_settings.multimodal_projection + == MultimodalProjection::File(projection_file.path().to_path_buf()) + )); + } + + #[tokio::test] + async fn a_text_generation_state_without_a_model_keeps_its_chat_template() { + let chat_template = ChatTemplate { + content: "{{ messages }}".to_owned(), + }; + + assert_eq!( + converted(&AgentDesiredState { + inference_settings: AgentInferenceSettings::TextGeneration( + AgentTextGenerationSettings { + chat_template_source: ChatTemplateSource::Override(chat_template.clone()), + ..AgentTextGenerationSettings::default() + }, + ), + model: AgentDesiredModel::None, + model_runtime_parameters: ModelRuntimeParameters::default(), + }) + .await + .chat_template_override(), + Some(chat_template) + ); + } + + #[tokio::test] + async fn a_decision_state_carries_its_model_and_pointer_head() { + let model_file = NamedTempFile::new().unwrap(); + let pointer_head_file = NamedTempFile::new().unwrap(); + + assert_eq!( + converted(&AgentDesiredState { + inference_settings: AgentInferenceSettings::Decision(DecisionSettings { + pointer_head: local_model(&pointer_head_file), + }), + model: local_model(&model_file), + model_runtime_parameters: ModelRuntimeParameters::default(), + }) + .await, + AgentApplicableState::Decision { + model_path: model_file.path().to_path_buf(), + model_runtime_parameters: ModelRuntimeParameters::default(), + pointer_head_path: pointer_head_file.path().to_path_buf(), + } + ); + } + + #[tokio::test] + async fn a_decision_state_without_a_pointer_head_is_not_configured() { + let model_file = NamedTempFile::new().unwrap(); + + assert_eq!( + converted(&AgentDesiredState { + inference_settings: AgentInferenceSettings::Decision(DecisionSettings::default()), + model: local_model(&model_file), + model_runtime_parameters: ModelRuntimeParameters::default(), + }) + .await, + AgentApplicableState::NotConfigured + ); + } } diff --git a/paddler_agent/src/agent_error.rs b/paddler_agent/src/agent_error.rs new file mode 100644 index 000000000..6fc21cf5f --- /dev/null +++ b/paddler_agent/src/agent_error.rs @@ -0,0 +1,15 @@ +use paddler_agent_decision::decision_error::DecisionError; +use paddler_agent_embeddings::embedding_error::EmbeddingError; +use paddler_agent_text_generation::text_generation_error::TextGenerationError; + +#[derive(Debug, thiserror::Error)] +pub enum AgentError { + #[error("the decision pipeline failed")] + DecisionPipeline(#[from] DecisionError), + + #[error("the embedding pipeline failed")] + EmbeddingPipeline(#[from] EmbeddingError), + + #[error("the text generation pipeline failed")] + TextGenerationPipeline(#[from] TextGenerationError), +} diff --git a/paddler_agent/src/balancer_message_context.rs b/paddler_agent/src/balancer_message_context.rs index aa5aefa9b..64f7bf483 100644 --- a/paddler_agent/src/balancer_message_context.rs +++ b/paddler_agent/src/balancer_message_context.rs @@ -2,20 +2,19 @@ use std::sync::Arc; use tokio::sync::mpsc; +use paddler_agent_runtime::model_metadata_holder::ModelMetadataHolder; use paddler_agent_status::slot_aggregated_status::SlotAggregatedStatus; use paddler_messaging::agent_desired_state::AgentDesiredState; use paddler_request_registry::request_registry::RequestRegistry; use crate::agent_applicable_state_holder::AgentApplicableStateHolder; -use crate::continuous_batch_preparation_request::ContinuousBatchPreparationRequest; -use crate::model_metadata_holder::ModelMetadataHolder; +use crate::pipeline_request::PipelineRequest; #[derive(Clone)] pub struct BalancerMessageContext { pub agent_applicable_state_holder: Arc, pub agent_desired_state_tx: mpsc::UnboundedSender, - pub continuous_batch_preparation_request_tx: - mpsc::UnboundedSender, + pub pipeline_request_tx: mpsc::UnboundedSender, pub model_metadata_holder: Arc, pub request_stoppers: Arc>>, pub slot_aggregated_status: Arc, diff --git a/paddler_agent/src/chat_template_load_status.rs b/paddler_agent/src/chat_template_load_status.rs deleted file mode 100644 index 1852f5004..000000000 --- a/paddler_agent/src/chat_template_load_status.rs +++ /dev/null @@ -1,4 +0,0 @@ -pub enum ChatTemplateLoadStatus { - Loaded, - SkippedForEmbeddings, -} diff --git a/paddler_agent/src/continuous_batch_arbiter.rs b/paddler_agent/src/continuous_batch_arbiter.rs deleted file mode 100644 index 32b0656b4..000000000 --- a/paddler_agent/src/continuous_batch_arbiter.rs +++ /dev/null @@ -1,525 +0,0 @@ -use std::cmp::max; -use std::path::PathBuf; -use std::sync::Arc; -use std::sync::mpsc::channel; -use std::thread; -use std::thread::available_parallelism; - -use anyhow::Context as _; -use anyhow::Result; -use encoding_rs::Decoder; -use encoding_rs::UTF_8; -use llama_cpp_bindings::SampledToken; -use llama_cpp_bindings::context::LlamaContext; -use llama_cpp_bindings::context::params::LlamaContextParams; -use llama_cpp_bindings::error::TokenToStringError; -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; -use llama_cpp_bindings::mtmd::MtmdContextParams; -use llama_cpp_bindings::mtmd::micro_batch_tokens; -use llama_cpp_bindings::mtmd::mtmd_default_marker; -use llama_cpp_bindings::token::LlamaToken; -use llama_cpp_bindings_sys::LLAMA_FLASH_ATTN_TYPE_AUTO; -use log::debug; -use log::error; -use log::info; -use log::warn; -use tokio::sync::oneshot; -use tokio::task::spawn_blocking; -use tokio_util::sync::CancellationToken; -use tokio_util::task::TaskTracker; - -use paddler_agent_status::agent_issue_fix::AgentIssueFix; -use paddler_inference_parameters::inference_parameters::InferenceParameters; -use paddler_messaging::agent_issue::AgentIssue; -use paddler_messaging::agent_issue_params::chat_template_does_not_compile_params::ChatTemplateDoesNotCompileParams; -use paddler_messaging::agent_issue_params::model_path::ModelPath; -use paddler_messaging::agent_issue_params::slot_cannot_start_params::SlotCannotStartParams; -use paddler_messaging::chat_template::ChatTemplate; -use paddler_messaging::media_marker::MediaMarker; -use paddler_messaging::model_metadata::ModelMetadata; - -use crate::agent_kv_cache_dtype::AgentKvCacheDtype; -use crate::agent_pooling_type::AgentPoolingType; -use crate::chat_prompt_renderer::ChatPromptRenderer; -use crate::chat_template_load_status::ChatTemplateLoadStatus; -use crate::chat_template_renderer::ChatTemplateRenderer; -use crate::continuous_batch_arbiter_context::ContinuousBatchArbiterContext; -use crate::continuous_batch_arbiter_handle::ContinuousBatchArbiterHandle; -use crate::continuous_batch_arbiter_spawn_outcome::ContinuousBatchArbiterSpawnOutcome; -use crate::continuous_batch_request_preparer::ContinuousBatchRequestPreparer; -use crate::continuous_batch_scheduler::ContinuousBatchScheduler; -use crate::continuous_batch_scheduler_context::ContinuousBatchSchedulerContext; -use crate::continuous_batch_scheduler_params::ContinuousBatchSchedulerParams; -use crate::converts_to_llama_kv_cache_dtype::ConvertsToLlamaKvCacheDtype; -use crate::converts_to_llama_pooling_type::ConvertsToLlamaPoolingType; -use crate::embedding_batch_preparer::EmbeddingBatchPreparer; -use crate::generation_request_preparer::GenerationRequestPreparer; -use crate::image_input::ImageInput; -use crate::join_scheduler_thread::join_scheduler_thread; -use crate::multimodal_prompt_support::MultimodalPromptSupport; -use crate::prompt_tokenizer::PromptTokenizer; -use crate::sampler_chain_factory::SamplerChainFactory; -use crate::send_startup_signal::send_startup_signal; -use crate::startup_signal_delivery::StartupSignalDelivery; -use crate::token_generation::TokenGeneration; -use crate::token_generation_support::TokenGenerationSupport; - -const LOGICAL_CORES_PER_PHYSICAL_CORE: i32 = 2; -const MINIMUM_THREAD_COUNT: i32 = 2; - -fn special_token_text( - model: &LlamaModel, - token: LlamaToken, - decoder: &mut Decoder, -) -> Result { - model.token_to_piece(&SampledToken::Content(token), decoder, true, None) -} - -pub struct ContinuousBatchArbiter { - pub chat_template_override: Option, - pub context: ContinuousBatchArbiterContext, - pub inference_parameters: InferenceParameters, - pub model_path: PathBuf, - pub multimodal_projection_path: Option, -} - -impl ContinuousBatchArbiter { - pub async fn spawn( - &self, - cancellation_token: &CancellationToken, - ) -> Result { - if cancellation_token.is_cancelled() { - return Ok(ContinuousBatchArbiterSpawnOutcome::Cancelled); - } - - let desired_slots_total = self.context.slot_aggregated_status.desired_slots_total; - let n_seq_max = u32::from(desired_slots_total); - 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 thread_count = max( - MINIMUM_THREAD_COUNT, - available_parallelism_value / LOGICAL_CORES_PER_PHYSICAL_CORE, - ); - - info!("Using {thread_count} threads for generation and for batch processing"); - - let agent_name_clone = self.context.agent_name.clone(); - let inference_parameters = self.inference_parameters.clone(); - let model_metadata_holder = self.context.model_metadata_holder.clone(); - let multimodal_projection_path = self.multimodal_projection_path.clone(); - let model_path = self.model_path.clone(); - let model_issue_path = ModelPath::from(self.model_path.as_path()); - let chat_template_override = self.chat_template_override.clone(); - let slot_aggregated_status = self.context.slot_aggregated_status.clone(); - let agent_shutdown = cancellation_token.clone(); - - let scheduler_thread_handle = thread::spawn(move || -> Result<()> { - let llama_backend = - Arc::new(LlamaBackend::init().context("Unable to initialize llama.cpp backend")?); - - let n_batch_tokens = inference_parameters.n_batch.tokens().get(); - - let default_context_params = LlamaContextParams::default(); - let n_ubatch = if inference_parameters.enable_embeddings { - n_batch_tokens - } else { - default_context_params.n_ubatch() - }; - - let context_params = default_context_params - .with_embeddings(inference_parameters.enable_embeddings) - .with_n_ctx(Some(inference_parameters.context_size)) - .with_n_batch(n_batch_tokens) - .with_n_ubatch(n_ubatch) - .with_flash_attention_policy(LLAMA_FLASH_ATTN_TYPE_AUTO) - .with_n_seq_max(n_seq_max) - .with_n_threads(thread_count) - .with_n_threads_batch(thread_count) - .with_pooling_type( - AgentPoolingType(inference_parameters.pooling_type.clone()) - .to_llama_pooling_type(), - ) - .with_type_k( - AgentKvCacheDtype(inference_parameters.k_cache_dtype.clone()) - .to_llama_kv_cache_dtype(), - ) - .with_type_v( - AgentKvCacheDtype(inference_parameters.v_cache_dtype.clone()) - .to_llama_kv_cache_dtype(), - ); - - let model = Arc::new( - LlamaModel::load_from_file( - &llama_backend, - model_path.clone(), - &LlamaModelParams::default() - .with_n_gpu_layers(inference_parameters.n_gpu_layers), - ) - .context("Unable to load model from file")?, - ); - - if matches!( - send_startup_signal(model_loaded_tx, ()), - StartupSignalDelivery::Abandoned - ) { - return Ok(()); - } - - let mut model_metadata = ModelMetadata::default(); - - for metadata_index in 0..model.meta_count() { - model_metadata.set_meta_field( - model.meta_key_by_index(metadata_index)?, - model.meta_val_str_by_index(metadata_index)?, - ); - } - - model_metadata_holder.set_model_metadata(model_metadata); - - let token_generation = if inference_parameters.enable_embeddings - && chat_template_override.is_none() - { - if matches!( - send_startup_signal( - chat_template_loaded_tx, - ChatTemplateLoadStatus::SkippedForEmbeddings, - ), - StartupSignalDelivery::Abandoned - ) { - return Ok(()); - } - - TokenGeneration::DisabledForEmbeddings - } else { - let llama_chat_template_string = match chat_template_override { - Some(chat_template) => chat_template.content, - None => model - .chat_template(None) - .context(format!( - "Failed to load chat template for model at path: {}", - model_path.display() - ))? - .to_string()?, - }; - - if matches!( - send_startup_signal(chat_template_loaded_tx, ChatTemplateLoadStatus::Loaded), - StartupSignalDelivery::Abandoned - ) { - return Ok(()); - } - - let chat_template_renderer = match ChatTemplateRenderer::new(ChatTemplate { - content: llama_chat_template_string.clone(), - }) - .context("Failed to create chat template renderer") - { - Ok(renderer) => { - slot_aggregated_status.register_fix( - &AgentIssueFix::ChatTemplateIsCompiled(model_issue_path.clone()), - ); - - renderer - } - Err(err) => { - slot_aggregated_status.register_issue( - AgentIssue::ChatTemplateDoesNotCompile( - ChatTemplateDoesNotCompileParams { - error: format!("{err}"), - model_path: model_issue_path.clone(), - template_content: llama_chat_template_string, - }, - ), - ); - - return Err(err); - } - }; - - let mut special_token_decoder = UTF_8.new_decoder(); - - TokenGeneration::Enabled(Box::new(TokenGenerationSupport { - chat_prompt_renderer: ChatPromptRenderer { - chat_template_renderer, - media_marker: MediaMarker::new(mtmd_default_marker()?.to_owned()), - token_bos_str: special_token_text( - &model, - model.token_bos(), - &mut special_token_decoder, - )?, - token_eos_str: special_token_text( - &model, - model.token_eos(), - &mut special_token_decoder, - )?, - token_nl_str: special_token_text( - &model, - model.token_nl(), - &mut special_token_decoder, - )?, - }, - streaming_markers: model.streaming_markers()?, - })) - }; - - slot_aggregated_status.set_model_path(Some(model_issue_path.model_path.clone())); - - 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..n_seq_max { - slot_aggregated_status.register_issue(AgentIssue::SlotCannotStart( - SlotCannotStartParams { - error: format!("{err:#}"), - slot_index, - }, - )); - } - - return Err(err); - } - }; - - let sequence_context_size = llama_context.n_ctx_seq(); - - let image_input = match multimodal_projection_path { - Some(multimodal_projection_path) => { - let multimodal_projection_path_str = - multimodal_projection_path.to_string_lossy(); - - match MtmdContext::init_from_file( - &multimodal_projection_path_str, - &model, - &MtmdContextParams::default(), - ) { - Ok(mtmd_context) => { - slot_aggregated_status.register_fix( - &AgentIssueFix::MultimodalProjectionIsLoaded(ModelPath::from( - multimodal_projection_path.as_path(), - )), - ); - - info!( - "Multimodal context initialized from: {}", - multimodal_projection_path.display() - ); - - ImageInput::Supported(MultimodalPromptSupport { - micro_batch_tokens: micro_batch_tokens( - &llama_context, - inference_parameters.n_batch.tokens(), - ), - multimodal_context: Arc::new(mtmd_context), - n_batch: inference_parameters.n_batch.tokens_i32(), - sequence_context_size, - }) - } - Err(err) => { - slot_aggregated_status.register_issue( - AgentIssue::MultimodalProjectionCannotBeLoaded(ModelPath::from( - multimodal_projection_path.as_path(), - )), - ); - - return Err(err.into()); - } - } - } - None => ImageInput::Unsupported, - }; - - let (scheduler_command_tx, scheduler_command_rx) = channel(); - - let request_preparer = Arc::new(ContinuousBatchRequestPreparer { - agent_name: agent_name_clone.clone(), - embedding_batch_preparer: EmbeddingBatchPreparer { - enable_embeddings: inference_parameters.enable_embeddings, - model: model.clone(), - n_batch: inference_parameters.n_batch.tokens_usize(), - }, - generation_request_preparer: GenerationRequestPreparer { - image_input, - image_resize_to_fit: inference_parameters.image_resize_to_fit, - model: model.clone(), - prompt_tokenizer: PromptTokenizer { - model: model.clone(), - sequence_context_size, - }, - sampler_chain_factory: SamplerChainFactory { - inference_parameters: inference_parameters.clone(), - n_vocab: model.n_vocab(), - }, - token_generation, - }, - llama_backend: llama_backend.clone(), - preparation_tasks: TaskTracker::new(), - scheduler_command_tx, - }); - - let mut batch = LlamaBatch::new( - inference_parameters.n_batch.tokens_usize(), - i32::from(desired_slots_total), - )?; - - Self::run_warmup_decode(&model, &mut llama_context, &mut batch, desired_slots_total); - - let mut scheduler = ContinuousBatchScheduler::new(ContinuousBatchSchedulerParams { - agent_shutdown, - batch, - command_rx: scheduler_command_rx, - llama_context, - scheduler_context: ContinuousBatchSchedulerContext { - agent_name: agent_name_clone, - desired_slots_total, - inference_parameters, - model: model.clone(), - }, - }); - - if matches!( - send_startup_signal(agent_warm_and_scheduler_running_tx, request_preparer), - StartupSignalDelivery::Abandoned - ) { - return Ok(()); - } - - scheduler.run(); - - Ok(()) - }); - - let Some(startup_result) = cancellation_token - .run_until_cancelled(self.await_startup_signals( - model_loaded_rx, - chat_template_loaded_rx, - agent_warm_and_scheduler_running_rx, - )) - .await - else { - if let Err(err) = spawn_blocking(move || join_scheduler_thread(scheduler_thread_handle)) - .await - .context("Failed to join the scheduler shutdown task")? - { - debug!("Scheduler thread ended while its spawn was being cancelled: {err}"); - } - - return Ok(ContinuousBatchArbiterSpawnOutcome::Cancelled); - }; - - let request_preparer = match startup_result { - Ok(request_preparer) => request_preparer, - Err(startup_error) => { - return spawn_blocking(move || join_scheduler_thread(scheduler_thread_handle)) - .await - .context("Failed to join the scheduler thread after its startup failed")? - .and(Err(startup_error)); - } - }; - - for slot_index in 0..n_seq_max { - self.context.slot_aggregated_status.increment_total_slots(); - - self.context - .slot_aggregated_status - .register_fix(&AgentIssueFix::SlotStarted(slot_index)); - } - - Ok(ContinuousBatchArbiterSpawnOutcome::Ready( - ContinuousBatchArbiterHandle { - request_preparer, - scheduler_thread_handle, - }, - )) - } - - async fn await_startup_signals( - &self, - model_loaded_rx: oneshot::Receiver<()>, - chat_template_loaded_rx: oneshot::Receiver, - agent_warm_and_scheduler_running_rx: oneshot::Receiver>, - ) -> Result> { - let model_issue_path = ModelPath::from(self.model_path.as_path()); - - match model_loaded_rx - .await - .context("Failed to receive model loaded signal") - { - Ok(()) => { - self.context - .slot_aggregated_status - .register_fix(&AgentIssueFix::ModelIsLoaded(model_issue_path.clone())); - } - Err(err) => { - error!("Failed to load model: {err}"); - - self.context - .slot_aggregated_status - .register_issue(AgentIssue::ModelCannotBeLoaded(model_issue_path.clone())); - } - } - - match chat_template_loaded_rx - .await - .context("Failed to receive chat template loaded signal") - { - Ok(ChatTemplateLoadStatus::Loaded) => { - self.context.slot_aggregated_status.register_fix( - &AgentIssueFix::ModelChatTemplateIsLoaded(model_issue_path.clone()), - ); - } - Ok(ChatTemplateLoadStatus::SkippedForEmbeddings) => {} - Err(err) => { - error!("Failed to load chat template: {err}"); - - if !self - .context - .slot_aggregated_status - .has_issue(&AgentIssue::ModelCannotBeLoaded(model_issue_path.clone())) - { - self.context - .slot_aggregated_status - .register_issue(AgentIssue::UnableToFindChatTemplate(model_issue_path)); - } - } - } - - agent_warm_and_scheduler_running_rx.await.context( - "Scheduler thread did not signal agent-warm-and-scheduler-running before exiting", - ) - } - - fn run_warmup_decode( - model: &LlamaModel, - llama_context: &mut LlamaContext<'_>, - warmup_batch: &mut LlamaBatch<'static>, - desired_slots_total: u16, - ) { - let warmup_tokens = [model.token_bos(), model.token_eos()]; - - for sequence_index in 0..i32::from(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(warmup_batch) { - warn!("Warmup decode failed: {err:#}"); - } - llama_context.synchronize(); - llama_context.clear_kv_cache(); - } -} diff --git a/paddler_agent/src/continuous_batch_arbiter_handle.rs b/paddler_agent/src/continuous_batch_arbiter_handle.rs deleted file mode 100644 index e4e60f4e7..000000000 --- a/paddler_agent/src/continuous_batch_arbiter_handle.rs +++ /dev/null @@ -1,35 +0,0 @@ -use std::sync::Arc; -use std::thread; - -use anyhow::Context as _; -use anyhow::Result; -use tokio::task::spawn_blocking; - -use crate::continuous_batch_request_preparer::ContinuousBatchRequestPreparer; -use crate::join_scheduler_thread::join_scheduler_thread; - -pub struct ContinuousBatchArbiterHandle { - pub request_preparer: Arc, - pub scheduler_thread_handle: thread::JoinHandle>, -} - -impl ContinuousBatchArbiterHandle { - pub async fn shutdown(self) -> Result<()> { - let Self { - request_preparer, - scheduler_thread_handle, - } = self; - - request_preparer - .shut_down_scheduler_after_pending_preparations() - .await; - - spawn_blocking(move || { - drop(request_preparer); - - join_scheduler_thread(scheduler_thread_handle) - }) - .await - .context("Failed to join the scheduler shutdown task")? - } -} diff --git a/paddler_agent/src/continuous_batch_arbiter_spawn_outcome.rs b/paddler_agent/src/continuous_batch_arbiter_spawn_outcome.rs deleted file mode 100644 index 24de55e8f..000000000 --- a/paddler_agent/src/continuous_batch_arbiter_spawn_outcome.rs +++ /dev/null @@ -1,6 +0,0 @@ -use crate::continuous_batch_arbiter_handle::ContinuousBatchArbiterHandle; - -pub enum ContinuousBatchArbiterSpawnOutcome { - Cancelled, - Ready(ContinuousBatchArbiterHandle), -} diff --git a/paddler_agent/src/continuous_batch_arbiter_state.rs b/paddler_agent/src/continuous_batch_arbiter_state.rs deleted file mode 100644 index f347f6c2d..000000000 --- a/paddler_agent/src/continuous_batch_arbiter_state.rs +++ /dev/null @@ -1,218 +0,0 @@ -use anyhow::Context as _; -use anyhow::Result; -use log::error; -use log::info; -use log::warn; -use tokio_util::sync::CancellationToken; - -use paddler_messaging::agent_state_application_status::AgentStateApplicationStatus; - -use crate::agent_applicable_model::AgentApplicableModel; -use crate::agent_applicable_state::AgentApplicableState; -use crate::continuous_batch_arbiter::ContinuousBatchArbiter; -use crate::continuous_batch_arbiter_context::ContinuousBatchArbiterContext; -use crate::continuous_batch_arbiter_handle::ContinuousBatchArbiterHandle; -use crate::continuous_batch_arbiter_spawn_outcome::ContinuousBatchArbiterSpawnOutcome; -use crate::continuous_batch_preparation_request::ContinuousBatchPreparationRequest; - -pub enum ContinuousBatchArbiterState { - Idle, - Running(ContinuousBatchArbiterHandle), -} - -impl ContinuousBatchArbiterState { - pub async fn apply( - self, - shutdown: &CancellationToken, - agent_applicable_state: AgentApplicableState, - arbiter_context: &ContinuousBatchArbiterContext, - ) -> Self { - match self - .try_to_apply(shutdown, agent_applicable_state, arbiter_context) - .await - { - Ok(next_state) => next_state, - Err(application_error) => { - error!("Failed to apply reconciled state change: {application_error}"); - - Self::Idle - } - } - } - - pub fn forward(&self, agent_name: Option<&str>, request: ContinuousBatchPreparationRequest) { - match self { - Self::Idle => request.reject_because_no_model_is_loaded(agent_name), - Self::Running(arbiter_handle) => arbiter_handle.request_preparer.prepare(request), - } - } - - pub async fn shut_down(self) -> Result<()> { - match self { - Self::Idle => Ok(()), - Self::Running(arbiter_handle) => arbiter_handle - .shutdown() - .await - .context("Arbiter shutdown returned an error"), - } - } - - async fn try_to_apply( - self, - shutdown: &CancellationToken, - agent_applicable_state: AgentApplicableState, - arbiter_context: &ContinuousBatchArbiterContext, - ) -> Result { - arbiter_context.slot_aggregated_status.reset(); - self.shut_down().await?; - - let next_state = match agent_applicable_state { - AgentApplicableState { - chat_template_override, - inference_parameters, - model: - AgentApplicableModel::Resolved { - model_path, - multimodal_projection_path, - }, - } => { - Self::spawn( - shutdown, - ContinuousBatchArbiter { - chat_template_override, - context: arbiter_context.clone(), - inference_parameters, - model_path, - multimodal_projection_path, - }, - ) - .await? - } - AgentApplicableState { - model: AgentApplicableModel::NotConfigured, - .. - } => { - warn!("No model configured in applicable state; skipping llama.cpp initialization"); - - Self::Idle - } - }; - - arbiter_context - .slot_aggregated_status - .set_state_application_status(AgentStateApplicationStatus::Applied); - - Ok(next_state) - } - - async fn spawn(shutdown: &CancellationToken, arbiter: ContinuousBatchArbiter) -> Result { - match arbiter.spawn(shutdown).await? { - ContinuousBatchArbiterSpawnOutcome::Ready(arbiter_handle) => { - info!("Reconciled state change applied successfully"); - - Ok(Self::Running(arbiter_handle)) - } - ContinuousBatchArbiterSpawnOutcome::Cancelled => { - info!("Model load was cancelled by shutdown before it finished"); - - Ok(Self::Idle) - } - } - } -} - -#[cfg(test)] -mod tests { - use std::mem::discriminant; - use std::num::NonZeroU32; - use std::sync::Arc; - - use tokio::sync::mpsc; - use tokio_util::sync::CancellationToken; - - use paddler_agent_status::slot_aggregated_status::SlotAggregatedStatus; - use paddler_messaging::agent_state_application_status::AgentStateApplicationStatus; - use paddler_messaging::generated_token_result::GeneratedTokenResult; - use paddler_messaging::request_params::continue_from_raw_prompt_params::ContinueFromRawPromptParams; - - use super::ContinuousBatchArbiterState; - use crate::agent_applicable_state::AgentApplicableState; - use crate::agent_request::AgentRequest; - use crate::continuous_batch_arbiter_context::ContinuousBatchArbiterContext; - use crate::continuous_batch_preparation_request::ContinuousBatchPreparationRequest; - use crate::model_metadata_holder::ModelMetadataHolder; - use crate::slot_guard::SlotGuard; - - fn arbiter_context() -> ContinuousBatchArbiterContext { - ContinuousBatchArbiterContext { - agent_name: Some("agent".to_owned()), - model_metadata_holder: Arc::new(ModelMetadataHolder::default()), - slot_aggregated_status: Arc::new(SlotAggregatedStatus::new(1)), - } - } - - async fn applied( - agent_applicable_state: AgentApplicableState, - arbiter_context: &ContinuousBatchArbiterContext, - ) -> ContinuousBatchArbiterState { - ContinuousBatchArbiterState::Idle - .apply( - &CancellationToken::new(), - agent_applicable_state, - arbiter_context, - ) - .await - } - - #[tokio::test] - async fn an_idle_arbiter_rejects_a_request_because_no_model_is_loaded() { - let arbiter_context = arbiter_context(); - let (generated_tokens_tx, mut generated_tokens_rx) = mpsc::unbounded_channel(); - let (_generate_tokens_stop_tx, generate_tokens_stop_rx) = mpsc::unbounded_channel(); - - ContinuousBatchArbiterState::Idle.forward( - arbiter_context.agent_name.as_deref(), - ContinuousBatchPreparationRequest::ContinueFromRawPrompt(AgentRequest { - params: ContinueFromRawPromptParams { - grammar: None, - max_tokens: NonZeroU32::MIN, - raw_prompt: "Hello".to_owned(), - }, - response_tx: generated_tokens_tx, - slot_guard: SlotGuard::new(arbiter_context.slot_aggregated_status.clone()), - stop_rx: generate_tokens_stop_rx, - }), - ); - - assert_eq!( - generated_tokens_rx.recv().await, - Some(GeneratedTokenResult::ModelNotLoaded( - "agent: no model is loaded".to_owned() - )) - ); - assert_eq!( - arbiter_context - .slot_aggregated_status - .slots_processing_count(), - 0 - ); - } - - #[tokio::test] - async fn applying_a_state_without_a_model_stays_idle_and_reports_applied() { - let arbiter_context = arbiter_context(); - - let arbiter_state = applied(AgentApplicableState::default(), &arbiter_context).await; - - assert_eq!( - discriminant(&arbiter_state), - discriminant(&ContinuousBatchArbiterState::Idle) - ); - assert_eq!( - arbiter_context - .slot_aggregated_status - .get_state_application_status(), - AgentStateApplicationStatus::Applied - ); - } -} diff --git a/paddler_agent/src/continuous_batch_preparation_request.rs b/paddler_agent/src/continuous_batch_preparation_request.rs deleted file mode 100644 index 7dcdb4853..000000000 --- a/paddler_agent/src/continuous_batch_preparation_request.rs +++ /dev/null @@ -1,142 +0,0 @@ -use paddler_messaging::embedding_result::EmbeddingResult; -use paddler_messaging::generated_token_result::GeneratedTokenResult; -use paddler_messaging::request_params::continue_from_conversation_history_params::ContinueFromConversationHistoryParams; -use paddler_messaging::request_params::continue_from_conversation_history_params::tool::tool_params::function_call::parameters_schema::validated_parameters_schema::ValidatedParametersSchema; -use paddler_messaging::request_params::continue_from_raw_prompt_params::ContinueFromRawPromptParams; -use paddler_messaging::request_params::generate_embedding_batch_params::GenerateEmbeddingBatchParams; - -use crate::agent_request::AgentRequest; -use crate::embedding_batch_rejection::EmbeddingBatchRejection; -use crate::generation_request_rejection::GenerationRequestRejection; - -pub enum ContinuousBatchPreparationRequest { - ContinueFromConversationHistory( - AgentRequest< - ContinueFromConversationHistoryParams, - GeneratedTokenResult, - >, - ), - ContinueFromRawPrompt(AgentRequest), - GenerateEmbeddingBatch(AgentRequest), -} - -impl ContinuousBatchPreparationRequest { - pub fn reject_because_no_model_is_loaded(self, agent_name: Option<&str>) { - match self { - Self::ContinueFromConversationHistory(AgentRequest { response_tx, .. }) - | Self::ContinueFromRawPrompt(AgentRequest { response_tx, .. }) => { - GenerationRequestRejection::ModelNotLoaded.report(agent_name, &response_tx); - } - Self::GenerateEmbeddingBatch(AgentRequest { response_tx, .. }) => { - EmbeddingBatchRejection::ModelNotLoaded.report(agent_name, &response_tx); - } - } - } -} - -impl - From< - AgentRequest< - ContinueFromConversationHistoryParams, - GeneratedTokenResult, - >, - > for ContinuousBatchPreparationRequest -{ - fn from( - request: AgentRequest< - ContinueFromConversationHistoryParams, - GeneratedTokenResult, - >, - ) -> Self { - Self::ContinueFromConversationHistory(request) - } -} - -impl From> - for ContinuousBatchPreparationRequest -{ - fn from(request: AgentRequest) -> Self { - Self::ContinueFromRawPrompt(request) - } -} - -impl From> - for ContinuousBatchPreparationRequest -{ - fn from(request: AgentRequest) -> Self { - Self::GenerateEmbeddingBatch(request) - } -} - -#[cfg(test)] -mod tests { - use std::num::NonZeroU32; - use std::sync::Arc; - - use tokio::sync::mpsc; - - use paddler_agent_status::slot_aggregated_status::SlotAggregatedStatus; - use paddler_messaging::conversation_history::ConversationHistory; - use paddler_messaging::embedding_normalization_method::EmbeddingNormalizationMethod; - use paddler_messaging::embedding_result::EmbeddingResult; - use paddler_messaging::generated_token_result::GeneratedTokenResult; - use paddler_messaging::request_params::continue_from_conversation_history_params::ContinueFromConversationHistoryParams; - use paddler_messaging::request_params::generate_embedding_batch_params::GenerateEmbeddingBatchParams; - - use super::ContinuousBatchPreparationRequest; - use crate::agent_request::AgentRequest; - use crate::slot_guard::SlotGuard; - - #[test] - fn rejects_a_conversation_because_no_model_is_loaded() { - let (generated_tokens_tx, mut generated_tokens_rx) = mpsc::unbounded_channel(); - let (_generate_tokens_stop_tx, generate_tokens_stop_rx) = mpsc::unbounded_channel(); - - ContinuousBatchPreparationRequest::ContinueFromConversationHistory(AgentRequest { - params: ContinueFromConversationHistoryParams { - add_generation_prompt: true, - conversation_history: ConversationHistory::new(Vec::new()), - enable_thinking: false, - grammar: None, - max_tokens: NonZeroU32::MIN, - parse_tool_calls: false, - tools: Vec::new(), - }, - response_tx: generated_tokens_tx, - slot_guard: SlotGuard::new(Arc::new(SlotAggregatedStatus::new(1))), - stop_rx: generate_tokens_stop_rx, - }) - .reject_because_no_model_is_loaded(Some("agent")); - - assert_eq!( - generated_tokens_rx.try_recv(), - Ok(GeneratedTokenResult::ModelNotLoaded( - "agent: no model is loaded".to_owned() - )) - ); - } - - #[test] - fn rejects_an_embedding_batch_because_no_model_is_loaded() { - let (generated_embedding_tx, mut generated_embedding_rx) = mpsc::unbounded_channel(); - let (_generate_embedding_stop_tx, generate_embedding_stop_rx) = mpsc::unbounded_channel(); - - ContinuousBatchPreparationRequest::GenerateEmbeddingBatch(AgentRequest { - params: GenerateEmbeddingBatchParams { - input_batch: Vec::new(), - normalization_method: EmbeddingNormalizationMethod::None, - }, - response_tx: generated_embedding_tx, - slot_guard: SlotGuard::new(Arc::new(SlotAggregatedStatus::new(1))), - stop_rx: generate_embedding_stop_rx, - }) - .reject_because_no_model_is_loaded(Some("agent")); - - assert_eq!( - generated_embedding_rx.try_recv(), - Ok(EmbeddingResult::ModelNotLoaded( - "agent: no model is loaded".to_owned() - )) - ); - } -} diff --git a/paddler_agent/src/continuous_batch_request_preparer.rs b/paddler_agent/src/continuous_batch_request_preparer.rs deleted file mode 100644 index 6d23c7adb..000000000 --- a/paddler_agent/src/continuous_batch_request_preparer.rs +++ /dev/null @@ -1,100 +0,0 @@ -use std::sync::Arc; -use std::sync::mpsc::Sender; - -use llama_cpp_bindings::llama_backend::LlamaBackend; -use tokio_util::task::TaskTracker; - -use paddler_messaging::embedding_result::EmbeddingResult; -use paddler_messaging::generated_token_result::GeneratedTokenResult; -use paddler_messaging::request_params::generate_embedding_batch_params::GenerateEmbeddingBatchParams; - -use crate::agent_request::AgentRequest; -use crate::continuous_batch_preparation_request::ContinuousBatchPreparationRequest; -use crate::continuous_batch_scheduler_command::ContinuousBatchSchedulerCommand; -use crate::embedding_batch_preparer::EmbeddingBatchPreparer; -use crate::forward_scheduler_command::forward_scheduler_command; -use crate::generation_request_preparer::GenerationRequestPreparer; -use crate::generation_request_rejection::GenerationRequestRejection; -use crate::prepared_generation_request::PreparedGenerationRequest; - -pub struct ContinuousBatchRequestPreparer { - pub agent_name: Option, - pub embedding_batch_preparer: EmbeddingBatchPreparer, - pub generation_request_preparer: GenerationRequestPreparer, - pub llama_backend: Arc, - pub preparation_tasks: TaskTracker, - pub scheduler_command_tx: Sender, -} - -impl ContinuousBatchRequestPreparer { - pub fn prepare(self: &Arc, request: ContinuousBatchPreparationRequest) { - let preparer = self.clone(); - - self.preparation_tasks - .spawn_blocking(move || match request { - ContinuousBatchPreparationRequest::ContinueFromConversationHistory(request) => { - preparer.accept_generation( - request, - GenerationRequestPreparer::prepare_conversation_history, - ); - } - ContinuousBatchPreparationRequest::ContinueFromRawPrompt(request) => { - preparer - .accept_generation(request, GenerationRequestPreparer::prepare_raw_prompt); - } - ContinuousBatchPreparationRequest::GenerateEmbeddingBatch(request) => { - preparer.accept_embedding_batch(request); - } - }); - } - - pub async fn shut_down_scheduler_after_pending_preparations(&self) { - self.preparation_tasks.close(); - self.preparation_tasks.wait().await; - self.forward(ContinuousBatchSchedulerCommand::Shutdown); - } - - fn forward(&self, command: ContinuousBatchSchedulerCommand) { - forward_scheduler_command( - &self.scheduler_command_tx, - self.agent_name.as_deref(), - command, - ); - } - - fn accept_generation( - &self, - request: AgentRequest, - prepare: fn( - &GenerationRequestPreparer, - AgentRequest, - ) -> Result, - ) { - let generated_tokens_tx = request.response_tx.clone(); - - match prepare(&self.generation_request_preparer, request) { - Ok(prepared) => self.forward(ContinuousBatchSchedulerCommand::Generate(Box::new( - prepared, - ))), - Err(rejection) => rejection.report(self.agent_name.as_deref(), &generated_tokens_tx), - } - } - - fn accept_embedding_batch( - &self, - request: AgentRequest, - ) { - let generated_embedding_tx = request.response_tx.clone(); - - match self.embedding_batch_preparer.prepare(request) { - Ok(prepared) => { - self.forward(ContinuousBatchSchedulerCommand::GenerateEmbeddingBatch( - prepared, - )); - } - Err(rejection) => { - rejection.report(self.agent_name.as_deref(), &generated_embedding_tx); - } - } - } -} diff --git a/paddler_agent/src/continuous_batch_scheduler_command.rs b/paddler_agent/src/continuous_batch_scheduler_command.rs deleted file mode 100644 index b806ffa0c..000000000 --- a/paddler_agent/src/continuous_batch_scheduler_command.rs +++ /dev/null @@ -1,8 +0,0 @@ -use crate::prepared_embedding_batch_request::PreparedEmbeddingBatchRequest; -use crate::prepared_generation_request::PreparedGenerationRequest; - -pub enum ContinuousBatchSchedulerCommand { - Generate(Box), - GenerateEmbeddingBatch(PreparedEmbeddingBatchRequest), - Shutdown, -} diff --git a/paddler_agent/src/desired_state_reconciler.rs b/paddler_agent/src/desired_state_reconciler.rs index 3952aa2ee..6abe7ff4b 100644 --- a/paddler_agent/src/desired_state_reconciler.rs +++ b/paddler_agent/src/desired_state_reconciler.rs @@ -32,7 +32,8 @@ impl DesiredStateReconciler { match conversion { Ok(AgentDesiredStateConversion::Converted(applicable_state)) => { - let uses_chat_template_override = applicable_state.chat_template_override.is_some(); + let uses_chat_template_override = + applicable_state.chat_template_override().is_some(); self.agent_applicable_state_holder .set_agent_applicable_state(applicable_state); diff --git a/paddler_agent/src/embedding_batch_rejection.rs b/paddler_agent/src/embedding_batch_rejection.rs deleted file mode 100644 index 1e25cb031..000000000 --- a/paddler_agent/src/embedding_batch_rejection.rs +++ /dev/null @@ -1,129 +0,0 @@ -use std::num::TryFromIntError; - -use llama_cpp_bindings::batch_add_error::BatchAddError; -use llama_cpp_bindings::error::DecodeError; -use llama_cpp_bindings::error::EmbeddingsError; -use llama_cpp_bindings::error::StringToTokenError; -use log::error; -use tokio::sync::mpsc; -use tokio::sync::mpsc::error::SendError; - -use paddler_messaging::embedding_result::EmbeddingResult; - -use crate::rejection_description::rejection_description; -use crate::send_result_or_warn::send_result_or_warn; - -#[derive(Debug, thiserror::Error)] -pub enum EmbeddingBatchRejection { - #[error("embeddings are not enabled")] - EmbeddingsDisabled, - - #[error("failed to tokenize embedding input {source_document_id:?}: {source}")] - InputTokenizationFailed { - source_document_id: String, - #[source] - source: StringToTokenError, - }, - - #[error("no model is loaded")] - ModelNotLoaded, - - #[error("the scheduler is no longer accepting requests")] - SchedulerUnavailable, - - #[error("failed to add an embedding input to the batch: {0}")] - BatchAssemblyFailed(#[source] BatchAddError), - - #[error("the client stopped listening for embeddings: {0}")] - ClientDisconnected(#[source] SendError), - - #[error("llama.cpp rejected the embedding batch: {0}")] - DecodeFailed(#[source] DecodeError), - - #[error("{dimensions}-dimensional embedding is too long for RMS normalization: {source}")] - EmbeddingTooLongForRmsNormalization { - dimensions: usize, - #[source] - source: TryFromIntError, - }, - - #[error("failed to read the embedding of a batch sequence: {0}")] - EmbeddingsUnavailable(#[source] EmbeddingsError), - - #[error("embedding batch sequence index does not fit in i32: {0}")] - SequenceIndexOutOfRange(#[source] TryFromIntError), -} - -impl EmbeddingBatchRejection { - pub fn report( - self, - agent_name: Option<&str>, - generated_embedding_tx: &mpsc::UnboundedSender, - ) { - let message = rejection_description(agent_name, &self); - - error!("{message}"); - - let result = match self { - Self::EmbeddingsDisabled => EmbeddingResult::EmbeddingsDisabled, - Self::ModelNotLoaded => EmbeddingResult::ModelNotLoaded(message), - Self::InputTokenizationFailed { .. } - | Self::SchedulerUnavailable - | Self::BatchAssemblyFailed(_) - | Self::ClientDisconnected(_) - | Self::DecodeFailed(_) - | Self::EmbeddingTooLongForRmsNormalization { .. } - | Self::EmbeddingsUnavailable(_) - | Self::SequenceIndexOutOfRange(_) => EmbeddingResult::Error(message), - }; - - send_result_or_warn(agent_name, generated_embedding_tx, result); - } -} - -#[cfg(test)] -mod tests { - use tokio::sync::mpsc; - - use paddler_messaging::embedding_result::EmbeddingResult; - - use super::EmbeddingBatchRejection; - - #[test] - fn reports_disabled_embeddings() { - let (generated_embedding_tx, mut generated_embedding_rx) = mpsc::unbounded_channel(); - - EmbeddingBatchRejection::EmbeddingsDisabled.report(Some("agent"), &generated_embedding_tx); - - assert_eq!( - generated_embedding_rx.try_recv(), - Ok(EmbeddingResult::EmbeddingsDisabled) - ); - } - - #[test] - fn reports_other_rejections_as_errors_naming_the_cause() { - let (generated_embedding_tx, mut generated_embedding_rx) = mpsc::unbounded_channel(); - - EmbeddingBatchRejection::SchedulerUnavailable - .report(Some("agent"), &generated_embedding_tx); - - assert_eq!( - generated_embedding_rx.try_recv(), - Ok(EmbeddingResult::Error( - "agent: the scheduler is no longer accepting requests".to_owned() - )) - ); - } - - #[test] - fn tolerates_a_disconnected_client() { - let (generated_embedding_tx, generated_embedding_rx) = mpsc::unbounded_channel(); - - drop(generated_embedding_rx); - - EmbeddingBatchRejection::EmbeddingsDisabled.report(None, &generated_embedding_tx); - - assert!(generated_embedding_tx.is_closed()); - } -} diff --git a/paddler_agent/src/forward_scheduler_command.rs b/paddler_agent/src/forward_scheduler_command.rs deleted file mode 100644 index 4dfaacac3..000000000 --- a/paddler_agent/src/forward_scheduler_command.rs +++ /dev/null @@ -1,174 +0,0 @@ -use std::sync::mpsc::SendError; -use std::sync::mpsc::Sender; - -use log::info; - -use crate::continuous_batch_scheduler_command::ContinuousBatchSchedulerCommand; -use crate::embedding_batch_rejection::EmbeddingBatchRejection; -use crate::generation_request_rejection::GenerationRequestRejection; - -pub fn forward_scheduler_command( - scheduler_command_tx: &Sender, - agent_name: Option<&str>, - command: ContinuousBatchSchedulerCommand, -) { - if let Err(SendError(unsent_command)) = scheduler_command_tx.send(command) { - match unsent_command { - ContinuousBatchSchedulerCommand::Generate(request) => { - GenerationRequestRejection::SchedulerUnavailable - .report(agent_name, &request.generated_tokens_tx); - } - ContinuousBatchSchedulerCommand::GenerateEmbeddingBatch(request) => { - EmbeddingBatchRejection::SchedulerUnavailable - .report(agent_name, &request.generated_embedding_tx); - } - ContinuousBatchSchedulerCommand::Shutdown => { - info!("{agent_name:?}: scheduler stopped before the shutdown command arrived"); - } - } - } -} - -#[cfg(test)] -mod tests { - use std::mem::discriminant; - use std::num::NonZeroU32; - use std::sync::Arc; - use std::sync::mpsc::channel; - - use llama_cpp_bindings::BareJsonToolCalls; - use llama_cpp_bindings::StreamingMarkers; - use tokio::sync::mpsc; - - use paddler_agent_status::slot_aggregated_status::SlotAggregatedStatus; - use paddler_inference_parameters::inference_parameters::InferenceParameters; - use paddler_messaging::embedding_normalization_method::EmbeddingNormalizationMethod; - use paddler_messaging::embedding_result::EmbeddingResult; - use paddler_messaging::generated_token_result::GeneratedTokenResult; - - use super::forward_scheduler_command; - use crate::continuous_batch_scheduler_command::ContinuousBatchSchedulerCommand; - use crate::grammar_sampling::GrammarSampling; - use crate::prepared_embedding_batch_request::PreparedEmbeddingBatchRequest; - use crate::prepared_generation_request::PreparedGenerationRequest; - use crate::prepared_prompt::PreparedPrompt; - use crate::sampler_chain_factory::SamplerChainFactory; - use crate::slot_guard::SlotGuard; - use crate::token_classification::TokenClassification; - use crate::token_sampling::TokenSampling; - use crate::tool_call_handling::ToolCallHandling; - - fn slot_guard() -> SlotGuard { - SlotGuard::new(Arc::new(SlotAggregatedStatus::new(1))) - } - - #[test] - fn delivers_command_to_a_running_scheduler() { - let (scheduler_command_tx, scheduler_command_rx) = channel(); - - forward_scheduler_command( - &scheduler_command_tx, - None, - ContinuousBatchSchedulerCommand::Shutdown, - ); - - assert_eq!( - scheduler_command_rx - .try_recv() - .map(|command| discriminant(&command)), - Ok(discriminant(&ContinuousBatchSchedulerCommand::Shutdown)) - ); - } - - #[test] - fn rejects_generation_when_the_scheduler_stopped() { - let (scheduler_command_tx, scheduler_command_rx) = channel(); - let (generated_tokens_tx, mut generated_tokens_rx) = mpsc::unbounded_channel(); - let (_generate_tokens_stop_tx, generate_tokens_stop_rx) = mpsc::unbounded_channel(); - - drop(scheduler_command_rx); - - forward_scheduler_command( - &scheduler_command_tx, - Some("agent"), - ContinuousBatchSchedulerCommand::Generate(Box::new(PreparedGenerationRequest { - generate_tokens_stop_rx, - generated_tokens_tx, - max_tokens: NonZeroU32::new(1).unwrap(), - prompt: PreparedPrompt::TextTokens(Vec::new()), - slot_guard: slot_guard(), - token_classification: TokenClassification { - bare_json_tool_calls: BareJsonToolCalls::Ignore, - streaming_markers: Arc::new(StreamingMarkers::default()), - }, - token_sampling: TokenSampling { - chain: SamplerChainFactory { - inference_parameters: InferenceParameters::default(), - n_vocab: 1, - } - .create(0) - .unwrap(), - grammar: GrammarSampling::Unconstrained, - }, - tool_call_handling: ToolCallHandling::Streamed, - })), - ); - - assert_eq!( - generated_tokens_rx.try_recv(), - Ok(GeneratedTokenResult::SchedulerUnavailable( - "agent: the scheduler is no longer accepting requests".to_owned() - )) - ); - } - - #[test] - fn rejects_embedding_batch_when_the_scheduler_stopped() { - let (scheduler_command_tx, scheduler_command_rx) = channel(); - let (generated_embedding_tx, mut generated_embedding_rx) = mpsc::unbounded_channel(); - let (_generate_embedding_stop_tx, generate_embedding_stop_rx) = mpsc::unbounded_channel(); - - drop(scheduler_command_rx); - - forward_scheduler_command( - &scheduler_command_tx, - Some("agent"), - ContinuousBatchSchedulerCommand::GenerateEmbeddingBatch( - PreparedEmbeddingBatchRequest { - generate_embedding_stop_rx, - generated_embedding_tx, - inputs: Vec::new(), - normalization_method: EmbeddingNormalizationMethod::None, - oversized_documents: Vec::new(), - slot_guard: slot_guard(), - }, - ), - ); - - assert_eq!( - generated_embedding_rx.try_recv(), - Ok(EmbeddingResult::Error( - "agent: the scheduler is no longer accepting requests".to_owned() - )) - ); - } - - #[test] - fn accepts_shutdown_after_the_scheduler_stopped() { - let (scheduler_command_tx, scheduler_command_rx) = channel(); - - drop(scheduler_command_rx); - - forward_scheduler_command( - &scheduler_command_tx, - None, - ContinuousBatchSchedulerCommand::Shutdown, - ); - - assert!( - scheduler_command_tx - .send(ContinuousBatchSchedulerCommand::Shutdown) - .is_err() - ); - } -} diff --git a/paddler_agent/src/lib.rs b/paddler_agent/src/lib.rs index b447c0d35..0bfbaeeac 100644 --- a/paddler_agent/src/lib.rs +++ b/paddler_agent/src/lib.rs @@ -1,83 +1,15 @@ -pub mod agent_applicable_model; pub mod agent_applicable_state; pub mod agent_applicable_state_holder; pub mod agent_desired_state_conversion; pub mod agent_desired_state_converter; -pub mod agent_kv_cache_dtype; -pub mod agent_pooling_type; -pub mod agent_request; +pub mod agent_error; pub mod balancer_message_context; -pub mod chat_prompt_render_request; -pub mod chat_prompt_renderer; -pub mod chat_template_load_status; -pub mod chat_template_renderer; -pub mod continuous_batch_active_request; -pub mod continuous_batch_arbiter; -pub mod continuous_batch_arbiter_context; -pub mod continuous_batch_arbiter_handle; -pub mod continuous_batch_arbiter_spawn_outcome; -pub mod continuous_batch_arbiter_state; -pub mod continuous_batch_embedding_processor; -pub mod continuous_batch_generation_step; -pub mod continuous_batch_preparation_request; -pub mod continuous_batch_request_phase; -pub mod continuous_batch_request_preparer; -pub mod continuous_batch_request_state; -pub mod continuous_batch_scheduler; -pub mod continuous_batch_scheduler_command; -pub mod continuous_batch_scheduler_context; -pub mod continuous_batch_scheduler_params; -pub mod continuous_batch_terminal_delivery; -pub mod continuous_batch_terminal_outcome; -pub mod converts_to_llama_kv_cache_dtype; -pub mod converts_to_llama_pooling_type; -pub mod converts_to_mtmd_bitmap; pub mod desired_state_reconciler; pub mod desired_state_reconciliation; -pub mod embedding_batch_preparer; -pub mod embedding_batch_rejection; -pub mod embedding_input_tokenized; pub mod forward_management_socket_messages; -pub mod forward_scheduler_command; -pub mod generation_request_preparer; -pub mod generation_request_rejection; -pub mod grammar_sampling; -pub mod image_input; -pub mod join_scheduler_thread; pub mod llamacpp_arbiter_service; pub mod management_socket_client_service; -pub mod model_bos_token; -pub mod model_metadata_holder; -pub mod multimodal_ingestion_progress; -pub mod multimodal_prompt_ingestion; -pub mod multimodal_prompt_support; -pub mod normalization; -pub mod plan_embedding_batches; -pub mod prepared_embedding_batch_request; -pub mod prepared_generation_request; -pub mod prepared_prompt; -pub mod prompt_modality; -pub mod prompt_tokenizer; -pub mod receives_stop_request; +pub mod pipeline_arbiter_state; +pub mod pipeline_request; pub mod reconciliation_service; -pub mod rejection_description; -pub mod require_embeddings_enabled; -pub mod require_grammar_compatible_with_thinking; -pub mod require_prompt_fits_sequence_context; -pub mod resolve_grammar; -pub mod sampler_chain_factory; -pub mod sampling_outcome; -pub mod send_result_or_warn; -pub mod send_startup_signal; -pub mod sequence_id_guard; -pub mod sequence_id_pool; -pub mod slot_guard; -pub mod startup_signal_delivery; -pub mod token_classification; -pub mod token_generation; -pub mod token_generation_support; -pub mod token_sampling; -pub mod tool_call_buffer; -pub mod tool_call_event; -pub mod tool_call_handling; -pub mod tool_call_pipeline; +pub mod running_pipeline; diff --git a/paddler_agent/src/llamacpp_arbiter_service.rs b/paddler_agent/src/llamacpp_arbiter_service.rs index a03c47843..bfba9d3dc 100644 --- a/paddler_agent/src/llamacpp_arbiter_service.rs +++ b/paddler_agent/src/llamacpp_arbiter_service.rs @@ -12,20 +12,19 @@ use tokio::time::interval_at; use tokio_util::sync::CancellationToken; use trzcina::Service; +use paddler_agent_runtime::inference_runtime_context::InferenceRuntimeContext; use paddler_messaging::agent_state_application_status::AgentStateApplicationStatus; use crate::agent_applicable_state_holder::AgentApplicableStateHolder; -use crate::continuous_batch_arbiter_context::ContinuousBatchArbiterContext; -use crate::continuous_batch_arbiter_state::ContinuousBatchArbiterState; -use crate::continuous_batch_preparation_request::ContinuousBatchPreparationRequest; +use crate::pipeline_arbiter_state::PipelineArbiterState; +use crate::pipeline_request::PipelineRequest; const STATE_APPLICATION_RETRY_INTERVAL: Duration = Duration::from_secs(1); pub struct LlamaCppArbiterService { pub agent_applicable_state_holder: Arc, - pub arbiter_context: ContinuousBatchArbiterContext, - pub continuous_batch_preparation_request_rx: - mpsc::UnboundedReceiver, + pub inference_runtime_context: InferenceRuntimeContext, + pub pipeline_request_rx: mpsc::UnboundedReceiver, } #[async_trait] @@ -37,11 +36,11 @@ impl Service for LlamaCppArbiterService { async fn run(self: Box, shutdown: CancellationToken) -> Result<()> { let Self { agent_applicable_state_holder, - arbiter_context, - mut continuous_batch_preparation_request_rx, + inference_runtime_context, + mut pipeline_request_rx, } = *self; - let mut arbiter_state = ContinuousBatchArbiterState::Idle; + let mut arbiter_state = PipelineArbiterState::Idle; let mut reconciled_state = agent_applicable_state_holder.subscribe(); let mut ticker = interval_at( Instant::now() + STATE_APPLICATION_RETRY_INTERVAL, @@ -55,7 +54,7 @@ impl Service for LlamaCppArbiterService { biased; () = shutdown.cancelled() => break Ok(()), _ = ticker.tick() => { - let slot_aggregated_status = &arbiter_context.slot_aggregated_status; + let slot_aggregated_status = &inference_runtime_context.slot_aggregated_status; let current_status = slot_aggregated_status.get_state_application_status(); if current_status.should_try_to_apply() { @@ -70,23 +69,23 @@ impl Service for LlamaCppArbiterService { let agent_applicable_state = reconciled_state.borrow().clone(); arbiter_state = arbiter_state - .apply(&shutdown, agent_applicable_state, &arbiter_context) + .apply(&shutdown, agent_applicable_state, &inference_runtime_context) .await; } } Ok(()) = reconciled_state.changed() => { let agent_applicable_state = reconciled_state.borrow_and_update().clone(); - arbiter_context + inference_runtime_context .slot_aggregated_status .set_state_application_status(AgentStateApplicationStatus::Fresh); arbiter_state = arbiter_state - .apply(&shutdown, agent_applicable_state, &arbiter_context) + .apply(&shutdown, agent_applicable_state, &inference_runtime_context) .await; } - request = continuous_batch_preparation_request_rx.recv() => match request { - Some(request) => arbiter_state.forward(arbiter_context.agent_name.as_deref(), request), + request = pipeline_request_rx.recv() => match request { + Some(request) => arbiter_state.forward(inference_runtime_context.agent_name.as_deref(), request), None => break Ok(()), }, } @@ -108,28 +107,27 @@ mod tests { use tokio_util::sync::CancellationToken; use trzcina::Service; + use paddler_agent_runtime::inference_runtime_context::InferenceRuntimeContext; + use paddler_agent_runtime::model_metadata_holder::ModelMetadataHolder; use paddler_agent_status::slot_aggregated_status::SlotAggregatedStatus; use super::LlamaCppArbiterService; use crate::agent_applicable_state_holder::AgentApplicableStateHolder; - use crate::continuous_batch_arbiter_context::ContinuousBatchArbiterContext; - use crate::model_metadata_holder::ModelMetadataHolder; #[tokio::test(flavor = "multi_thread")] async fn exits_when_its_request_channel_closes() { - let (continuous_batch_preparation_request_tx, continuous_batch_preparation_request_rx) = - mpsc::unbounded_channel(); + let (pipeline_request_tx, pipeline_request_rx) = mpsc::unbounded_channel(); let service = LlamaCppArbiterService { agent_applicable_state_holder: Arc::new(AgentApplicableStateHolder::default()), - arbiter_context: ContinuousBatchArbiterContext { + inference_runtime_context: InferenceRuntimeContext { agent_name: None, model_metadata_holder: Arc::new(ModelMetadataHolder::default()), slot_aggregated_status: Arc::new(SlotAggregatedStatus::new(1)), }, - continuous_batch_preparation_request_rx, + pipeline_request_rx, }; - drop(continuous_batch_preparation_request_tx); + drop(pipeline_request_tx); Box::new(service) .run(CancellationToken::new()) diff --git a/paddler_agent/src/management_socket_client_service.rs b/paddler_agent/src/management_socket_client_service.rs index f72775ec0..7cf6de3a6 100644 --- a/paddler_agent/src/management_socket_client_service.rs +++ b/paddler_agent/src/management_socket_client_service.rs @@ -25,6 +25,7 @@ use tokio_util::sync::CancellationToken; use trzcina::Service; use paddler_messaging::balancer_connection::BalancerConnection; +use paddler_messaging::decision_result::DecisionResult; use paddler_messaging::embedding_result::EmbeddingResult; use paddler_messaging::generated_token_result::GeneratedTokenResult; use paddler_messaging::jsonrpc::request_envelope::RequestEnvelope; @@ -42,12 +43,12 @@ use paddler_messaging::produces_snapshot::ProducesSnapshot; use paddler_messaging::subscribes_to_updates::SubscribesToUpdates as _; use paddler_request_registry::request_delivery::RequestDelivery; use paddler_request_registry::request_registry_guard::RequestRegistryGuard; +use paddler_agent_runtime::agent_request::AgentRequest; +use paddler_agent_status::slot_guard::SlotGuard; -use crate::agent_request::AgentRequest; use crate::balancer_message_context::BalancerMessageContext; -use crate::continuous_batch_preparation_request::ContinuousBatchPreparationRequest; use crate::forward_management_socket_messages::forward_management_socket_messages; -use crate::slot_guard::SlotGuard; +use crate::pipeline_request::PipelineRequest; const BALANCER_RECONNECT_INTERVAL: Duration = Duration::from_secs(1); @@ -60,13 +61,13 @@ struct IncomingMessageContext { impl IncomingMessageContext { fn generate_responses(self, id: String, params: TParams) -> Result<()> where - AgentRequest: Into, + AgentRequest: Into, TResponse: Into + Send + 'static, { let Self { balancer_message_context: BalancerMessageContext { - continuous_batch_preparation_request_tx, + pipeline_request_tx, request_stoppers, slot_aggregated_status, .. @@ -80,7 +81,7 @@ impl IncomingMessageContext { let stopper_guard = RequestRegistryGuard::register(&request_stoppers, id.clone(), stop_tx) .context(format!("Failed to register stopper for request: {id}"))?; - continuous_batch_preparation_request_tx.send( + pipeline_request_tx.send( AgentRequest { params, response_tx, @@ -173,6 +174,10 @@ impl IncomingMessageContext { id, request: JsonRpcRequest::ContinueFromRawPrompt(generate_tokens_params), }) => self.generate_responses::<_, GeneratedTokenResult>(id, generate_tokens_params), + JsonRpcMessage::Request(RequestEnvelope { + id, + request: JsonRpcRequest::Decide(decide_params), + }) => self.generate_responses::<_, DecisionResult>(id, decide_params), JsonRpcMessage::Request(RequestEnvelope { id, request: JsonRpcRequest::GenerateEmbeddingBatch(generate_embedding_batch_params), @@ -192,7 +197,7 @@ impl IncomingMessageContext { self.balancer_message_context .agent_applicable_state_holder .get_agent_applicable_state() - .chat_template_override, + .chat_template_override(), ), }))?) } @@ -455,6 +460,7 @@ mod tests { use paddler_agent_status::slot_aggregated_status::SlotAggregatedStatus; use paddler_messaging::agent_desired_state::AgentDesiredState; + use paddler_messaging::balancer_desired_state::BalancerDesiredState; use paddler_messaging::agent_status::AgentStatus; use paddler_messaging::api_path::ApiPath; use paddler_messaging::balancer_connection::BalancerConnection; @@ -473,28 +479,27 @@ mod tests { use paddler_messaging::model_metadata::ModelMetadata; use paddler_messaging::request_params::continue_from_raw_prompt_params::ContinueFromRawPromptParams; use paddler_messaging::slot_aggregated_status_snapshot::SlotAggregatedStatusSnapshot; + use paddler_messaging::inference_mode::InferenceMode; use paddler_request_registry::request_delivery::RequestDelivery; use paddler_request_registry::request_registry_guard::RequestRegistryGuard; + use paddler_agent_runtime::model_metadata_holder::ModelMetadataHolder; use super::IncomingMessageContext; use super::ManagementSocketClientService; use crate::agent_applicable_state_holder::AgentApplicableStateHolder; use crate::balancer_message_context::BalancerMessageContext; - use crate::continuous_batch_preparation_request::ContinuousBatchPreparationRequest; - use crate::model_metadata_holder::ModelMetadataHolder; + use crate::pipeline_request::PipelineRequest; struct IncomingMessageFixture { agent_desired_state_rx: mpsc::UnboundedReceiver, context: IncomingMessageContext, - continuous_batch_preparation_request_rx: - mpsc::UnboundedReceiver, + pipeline_request_rx: mpsc::UnboundedReceiver, message_rx: mpsc::UnboundedReceiver, } fn incoming_message_fixture() -> IncomingMessageFixture { let (agent_desired_state_tx, agent_desired_state_rx) = mpsc::unbounded_channel(); - let (continuous_batch_preparation_request_tx, continuous_batch_preparation_request_rx) = - mpsc::unbounded_channel(); + let (pipeline_request_tx, pipeline_request_rx) = mpsc::unbounded_channel(); let (message_tx, message_rx) = mpsc::unbounded_channel(); IncomingMessageFixture { @@ -503,7 +508,7 @@ mod tests { balancer_message_context: BalancerMessageContext { agent_applicable_state_holder: Arc::new(AgentApplicableStateHolder::default()), agent_desired_state_tx, - continuous_batch_preparation_request_tx, + pipeline_request_tx, model_metadata_holder: Arc::new(ModelMetadataHolder::new()), request_stoppers: Arc::default(), slot_aggregated_status: Arc::new(SlotAggregatedStatus::new(2)), @@ -511,7 +516,7 @@ mod tests { connection_close: CancellationToken::new(), message_tx, }, - continuous_batch_preparation_request_rx, + pipeline_request_rx, message_rx, } } @@ -536,9 +541,15 @@ mod tests { } } + fn unconfigured_desired_state() -> AgentDesiredState { + AgentDesiredState::from(BalancerDesiredState::unconfigured( + InferenceMode::TextGeneration, + )) + } + fn set_state_message() -> JsonRpcMessage { JsonRpcMessage::Notification(JsonRpcNotification::SetState(Box::new(SetStateParams { - desired_state: AgentDesiredState::default(), + desired_state: unconfigured_desired_state(), }))) } @@ -753,7 +764,7 @@ mod tests { assert_eq!( fixture.agent_desired_state_rx.try_recv().unwrap(), - AgentDesiredState::default() + unconfigured_desired_state() ); } @@ -931,7 +942,7 @@ mod tests { assert_eq!( fixture.agent_desired_state_rx.recv().await.unwrap(), - AgentDesiredState::default() + unconfigured_desired_state() ); } @@ -977,13 +988,13 @@ mod tests { .expect("the request must be accepted"); let dispatched_request = fixture - .continuous_batch_preparation_request_rx + .pipeline_request_rx .try_recv() .expect("the request must be dispatched to the arbiter"); assert!(matches!( &dispatched_request, - ContinuousBatchPreparationRequest::ContinueFromRawPrompt(request) + PipelineRequest::ContinueFromRawPrompt(request) if request.params.raw_prompt == "hello" )); assert_eq!( @@ -998,7 +1009,7 @@ mod tests { async fn generate_responses_errors_when_request_receiver_dropped() { let fixture = incoming_message_fixture(); - drop(fixture.continuous_batch_preparation_request_rx); + drop(fixture.pipeline_request_rx); assert!( fixture diff --git a/paddler_agent/src/model_metadata_holder.rs b/paddler_agent/src/model_metadata_holder.rs deleted file mode 100644 index e00a7d597..000000000 --- a/paddler_agent/src/model_metadata_holder.rs +++ /dev/null @@ -1,63 +0,0 @@ -use parking_lot::RwLock; - -use paddler_messaging::model_metadata::ModelMetadata; - -pub struct ModelMetadataHolder { - model_metadata: RwLock>, -} - -impl ModelMetadataHolder { - #[must_use] - pub fn new() -> Self { - Self::default() - } - - pub fn set_model_metadata(&self, metadata: ModelMetadata) { - let mut lock = self.model_metadata.write(); - - *lock = Some(metadata); - } - - pub fn get_model_metadata(&self) -> Option { - let lock = self.model_metadata.read(); - - lock.clone() - } -} - -impl Default for ModelMetadataHolder { - fn default() -> Self { - Self { - model_metadata: RwLock::new(None), - } - } -} - -#[cfg(test)] -mod tests { - use std::collections::BTreeMap; - - use paddler_messaging::model_metadata::ModelMetadata; - - use super::ModelMetadataHolder; - - #[test] - fn new_holder_starts_empty() { - let holder = ModelMetadataHolder::new(); - - assert!(holder.get_model_metadata().is_none()); - } - - #[test] - fn stored_metadata_is_returned() { - let holder = ModelMetadataHolder::new(); - let mut metadata = BTreeMap::new(); - metadata.insert("architecture".to_owned(), "llama".to_owned()); - - holder.set_model_metadata(ModelMetadata { metadata }); - - let stored = holder.get_model_metadata().unwrap(); - - assert_eq!(stored.metadata.get("architecture").unwrap(), "llama"); - } -} diff --git a/paddler_agent/src/pipeline_arbiter_state.rs b/paddler_agent/src/pipeline_arbiter_state.rs new file mode 100644 index 000000000..49aea1734 --- /dev/null +++ b/paddler_agent/src/pipeline_arbiter_state.rs @@ -0,0 +1,286 @@ +use log::error; +use log::info; +use log::warn; +use tokio_util::sync::CancellationToken; + +use paddler_agent_decision::decision_pipeline::DecisionPipeline; +use paddler_agent_embeddings::embedding_pipeline::EmbeddingPipeline; +use paddler_agent_runtime::inference_runtime_context::InferenceRuntimeContext; +use paddler_agent_runtime::prepares_scheduler_command::PreparesSchedulerCommand; +use paddler_agent_runtime::scheduler_handle::SchedulerHandle; +use paddler_agent_runtime::scheduler_spawn_outcome::SchedulerSpawnOutcome; +use paddler_agent_text_generation::text_generation_pipeline::TextGenerationPipeline; +use paddler_messaging::agent_state_application_status::AgentStateApplicationStatus; +use paddler_messaging::inference_mode::InferenceMode; + +use crate::agent_applicable_state::AgentApplicableState; +use crate::agent_error::AgentError; +use crate::pipeline_request::PipelineRequest; +use crate::running_pipeline::RunningPipeline; + +fn running_once_started( + inference_runtime_context: &InferenceRuntimeContext, + scheduler_spawn_outcome: SchedulerSpawnOutcome, + serving_inference_mode: InferenceMode, + running_pipeline: fn(SchedulerHandle) -> RunningPipeline, +) -> PipelineArbiterState +where + TPreparation: PreparesSchedulerCommand, +{ + match scheduler_spawn_outcome { + SchedulerSpawnOutcome::Ready(scheduler_handle) => { + inference_runtime_context + .slot_aggregated_status + .start_serving(serving_inference_mode); + + info!("Reconciled state change applied successfully"); + + PipelineArbiterState::Running(running_pipeline(scheduler_handle)) + } + SchedulerSpawnOutcome::Cancelled => { + info!("Model load was cancelled by shutdown before it finished"); + + PipelineArbiterState::Idle + } + } +} + +pub enum PipelineArbiterState { + Idle, + Running(RunningPipeline), +} + +impl PipelineArbiterState { + pub async fn apply( + self, + shutdown: &CancellationToken, + agent_applicable_state: AgentApplicableState, + inference_runtime_context: &InferenceRuntimeContext, + ) -> Self { + match self + .try_to_apply(shutdown, agent_applicable_state, inference_runtime_context) + .await + { + Ok(next_state) => next_state, + Err(application_error) => { + error!("Failed to apply reconciled state change: {application_error}"); + + Self::Idle + } + } + } + + pub fn forward(&self, agent_name: Option<&str>, request: PipelineRequest) { + match self { + Self::Idle => request.reject_because_no_model_is_loaded(agent_name), + Self::Running(running_pipeline) => running_pipeline.forward(agent_name, request), + } + } + + pub async fn shut_down(self) -> Result<(), AgentError> { + match self { + Self::Idle => Ok(()), + Self::Running(running_pipeline) => running_pipeline.shut_down().await, + } + } + + async fn try_to_apply( + self, + shutdown: &CancellationToken, + agent_applicable_state: AgentApplicableState, + inference_runtime_context: &InferenceRuntimeContext, + ) -> Result { + inference_runtime_context.slot_aggregated_status.reset(); + inference_runtime_context + .model_metadata_holder + .forget_model_metadata(); + self.shut_down().await?; + + let next_state = match agent_applicable_state { + AgentApplicableState::Decision { + model_path, + model_runtime_parameters, + pointer_head_path, + } => running_once_started( + inference_runtime_context, + DecisionPipeline { + inference_runtime_context: inference_runtime_context.clone(), + model_path, + model_runtime_parameters, + pointer_head_path, + } + .spawn(shutdown) + .await?, + InferenceMode::Decision, + RunningPipeline::Decision, + ), + AgentApplicableState::Embeddings { + embedding_parameters, + model_path, + model_runtime_parameters, + } => running_once_started( + inference_runtime_context, + EmbeddingPipeline { + embedding_parameters, + inference_runtime_context: inference_runtime_context.clone(), + model_path, + model_runtime_parameters, + } + .spawn(shutdown) + .await?, + InferenceMode::Embeddings, + RunningPipeline::Embeddings, + ), + AgentApplicableState::NotConfigured + | AgentApplicableState::TextGenerationWithoutModel { .. } => { + warn!("No model configured in applicable state; skipping llama.cpp initialization"); + + Self::Idle + } + AgentApplicableState::TextGeneration { + model_path, + model_runtime_parameters, + text_generation_settings, + } => running_once_started( + inference_runtime_context, + TextGenerationPipeline { + inference_runtime_context: inference_runtime_context.clone(), + model_path, + model_runtime_parameters, + text_generation_settings, + } + .spawn(shutdown) + .await?, + InferenceMode::TextGeneration, + RunningPipeline::TextGeneration, + ), + }; + + inference_runtime_context + .slot_aggregated_status + .set_state_application_status(AgentStateApplicationStatus::Applied); + + Ok(next_state) + } +} + +#[cfg(test)] +mod tests { + use std::collections::BTreeMap; + use std::mem::discriminant; + use std::num::NonZeroU32; + use std::sync::Arc; + + use tokio::sync::mpsc; + use tokio_util::sync::CancellationToken; + + use paddler_agent_runtime::agent_request::AgentRequest; + use paddler_agent_runtime::inference_runtime_context::InferenceRuntimeContext; + use paddler_agent_runtime::model_metadata_holder::ModelMetadataHolder; + use paddler_agent_status::slot_aggregated_status::SlotAggregatedStatus; + use paddler_agent_status::slot_guard::SlotGuard; + use paddler_messaging::agent_state_application_status::AgentStateApplicationStatus; + use paddler_messaging::generated_token_result::GeneratedTokenResult; + use paddler_messaging::model_metadata::ModelMetadata; + use paddler_messaging::request_params::continue_from_raw_prompt_params::ContinueFromRawPromptParams; + + use super::PipelineArbiterState; + use crate::agent_applicable_state::AgentApplicableState; + use crate::pipeline_request::PipelineRequest; + + fn inference_runtime_context() -> InferenceRuntimeContext { + InferenceRuntimeContext { + agent_name: Some("agent".to_owned()), + model_metadata_holder: Arc::new(ModelMetadataHolder::default()), + slot_aggregated_status: Arc::new(SlotAggregatedStatus::new(1)), + } + } + + async fn applied( + agent_applicable_state: AgentApplicableState, + inference_runtime_context: &InferenceRuntimeContext, + ) -> PipelineArbiterState { + PipelineArbiterState::Idle + .apply( + &CancellationToken::new(), + agent_applicable_state, + inference_runtime_context, + ) + .await + } + + #[tokio::test] + async fn an_idle_arbiter_rejects_a_request_because_no_model_is_loaded() { + let inference_runtime_context = inference_runtime_context(); + let (generated_tokens_tx, mut generated_tokens_rx) = mpsc::unbounded_channel(); + let (_generate_tokens_stop_tx, generate_tokens_stop_rx) = mpsc::unbounded_channel(); + + PipelineArbiterState::Idle.forward( + inference_runtime_context.agent_name.as_deref(), + PipelineRequest::ContinueFromRawPrompt(AgentRequest { + params: ContinueFromRawPromptParams { + grammar: None, + max_tokens: NonZeroU32::MIN, + raw_prompt: "Hello".to_owned(), + }, + response_tx: generated_tokens_tx, + slot_guard: SlotGuard::new( + inference_runtime_context.slot_aggregated_status.clone(), + ), + stop_rx: generate_tokens_stop_rx, + }), + ); + + assert_eq!( + generated_tokens_rx.recv().await, + Some(GeneratedTokenResult::ModelNotLoaded( + "agent: no model is loaded".to_owned() + )) + ); + assert_eq!( + inference_runtime_context + .slot_aggregated_status + .slots_processing_count(), + 0 + ); + } + + #[tokio::test] + async fn applying_a_state_without_a_model_stays_idle_and_reports_applied() { + let inference_runtime_context = inference_runtime_context(); + + let arbiter_state = + applied(AgentApplicableState::default(), &inference_runtime_context).await; + + assert_eq!( + discriminant(&arbiter_state), + discriminant(&PipelineArbiterState::Idle) + ); + assert_eq!( + inference_runtime_context + .slot_aggregated_status + .get_state_application_status(), + AgentStateApplicationStatus::Applied + ); + } + + #[tokio::test] + async fn applying_a_state_forgets_the_metadata_of_the_previous_model() { + let inference_runtime_context = inference_runtime_context(); + + inference_runtime_context + .model_metadata_holder + .set_model_metadata(ModelMetadata { + metadata: BTreeMap::from([("general.name".to_owned(), "previous".to_owned())]), + }); + + applied(AgentApplicableState::default(), &inference_runtime_context).await; + + assert_eq!( + inference_runtime_context + .model_metadata_holder + .get_model_metadata(), + None + ); + } +} diff --git a/paddler_agent/src/pipeline_request.rs b/paddler_agent/src/pipeline_request.rs new file mode 100644 index 000000000..667b8201b --- /dev/null +++ b/paddler_agent/src/pipeline_request.rs @@ -0,0 +1,277 @@ +use paddler_messaging::decision_result::DecisionResult; +use paddler_messaging::embedding_result::EmbeddingResult; +use paddler_messaging::inference_mode::InferenceMode; +use paddler_messaging::generated_token_result::GeneratedTokenResult; +use paddler_messaging::request_params::continue_from_conversation_history_params::ContinueFromConversationHistoryParams; +use paddler_messaging::request_params::continue_from_conversation_history_params::tool::tool_params::function_call::parameters_schema::validated_parameters_schema::ValidatedParametersSchema; +use paddler_messaging::request_params::continue_from_raw_prompt_params::ContinueFromRawPromptParams; +use paddler_messaging::request_params::decide_params::DecideParams; +use paddler_messaging::request_params::generate_embedding_batch_params::GenerateEmbeddingBatchParams; +use paddler_agent_decision::decision_error::DecisionError; +use paddler_agent_embeddings::embedding_error::EmbeddingError; +use paddler_agent_runtime::agent_request::AgentRequest; +use paddler_agent_text_generation::text_generation_error::TextGenerationError; + +pub enum PipelineRequest { + ContinueFromConversationHistory( + AgentRequest< + ContinueFromConversationHistoryParams, + GeneratedTokenResult, + >, + ), + ContinueFromRawPrompt(AgentRequest), + Decide(AgentRequest), + GenerateEmbeddingBatch(AgentRequest), +} + +impl PipelineRequest { + pub fn reject_because_another_mode_is_served( + self, + agent_name: Option<&str>, + serving_inference_mode: InferenceMode, + ) { + match self { + Self::ContinueFromConversationHistory(AgentRequest { response_tx, .. }) + | Self::ContinueFromRawPrompt(AgentRequest { response_tx, .. }) => { + TextGenerationError::InferenceModeMismatch { + serving_inference_mode, + } + .report(agent_name, &response_tx); + } + Self::Decide(AgentRequest { response_tx, .. }) => { + DecisionError::InferenceModeMismatch { + serving_inference_mode, + } + .report(agent_name, &response_tx); + } + Self::GenerateEmbeddingBatch(AgentRequest { response_tx, .. }) => { + EmbeddingError::InferenceModeMismatch { + serving_inference_mode, + } + .report(agent_name, &response_tx); + } + } + } + + pub fn reject_because_no_model_is_loaded(self, agent_name: Option<&str>) { + match self { + Self::ContinueFromConversationHistory(AgentRequest { response_tx, .. }) + | Self::ContinueFromRawPrompt(AgentRequest { response_tx, .. }) => { + TextGenerationError::ModelNotLoaded.report(agent_name, &response_tx); + } + Self::Decide(AgentRequest { response_tx, .. }) => { + DecisionError::ModelNotLoaded.report(agent_name, &response_tx); + } + Self::GenerateEmbeddingBatch(AgentRequest { response_tx, .. }) => { + EmbeddingError::ModelNotLoaded.report(agent_name, &response_tx); + } + } + } +} + +impl + From< + AgentRequest< + ContinueFromConversationHistoryParams, + GeneratedTokenResult, + >, + > for PipelineRequest +{ + fn from( + request: AgentRequest< + ContinueFromConversationHistoryParams, + GeneratedTokenResult, + >, + ) -> Self { + Self::ContinueFromConversationHistory(request) + } +} + +impl From> for PipelineRequest { + fn from(request: AgentRequest) -> Self { + Self::ContinueFromRawPrompt(request) + } +} + +impl From> for PipelineRequest { + fn from(request: AgentRequest) -> Self { + Self::Decide(request) + } +} + +impl From> for PipelineRequest { + fn from(request: AgentRequest) -> Self { + Self::GenerateEmbeddingBatch(request) + } +} + +#[cfg(test)] +mod tests { + use std::num::NonZeroU32; + use std::sync::Arc; + + use tokio::sync::mpsc; + + use paddler_agent_runtime::agent_request::AgentRequest; + use paddler_agent_status::slot_aggregated_status::SlotAggregatedStatus; + use paddler_agent_status::slot_guard::SlotGuard; + use paddler_messaging::conversation_history::ConversationHistory; + use paddler_messaging::decision_question::DecisionQuestion; + use paddler_messaging::decision_result::DecisionResult; + use paddler_messaging::embedding_normalization_method::EmbeddingNormalizationMethod; + use paddler_messaging::embedding_result::EmbeddingResult; + use paddler_messaging::generated_token_result::GeneratedTokenResult; + use paddler_messaging::inference_mode::InferenceMode; + use paddler_messaging::request_params::continue_from_conversation_history_params::ContinueFromConversationHistoryParams; + use paddler_messaging::request_params::continue_from_raw_prompt_params::ContinueFromRawPromptParams; + use paddler_messaging::request_params::decide_params::DecideParams; + use paddler_messaging::request_params::generate_embedding_batch_params::GenerateEmbeddingBatchParams; + + use super::PipelineRequest; + + #[test] + fn rejects_a_conversation_because_no_model_is_loaded() { + let (generated_tokens_tx, mut generated_tokens_rx) = mpsc::unbounded_channel(); + let (_generate_tokens_stop_tx, generate_tokens_stop_rx) = mpsc::unbounded_channel(); + + PipelineRequest::ContinueFromConversationHistory(AgentRequest { + params: ContinueFromConversationHistoryParams { + add_generation_prompt: true, + conversation_history: ConversationHistory::new(Vec::new()), + enable_thinking: false, + grammar: None, + max_tokens: NonZeroU32::MIN, + parse_tool_calls: false, + tools: Vec::new(), + }, + response_tx: generated_tokens_tx, + slot_guard: SlotGuard::new(Arc::new(SlotAggregatedStatus::new(1))), + stop_rx: generate_tokens_stop_rx, + }) + .reject_because_no_model_is_loaded(Some("agent")); + + assert_eq!( + generated_tokens_rx.try_recv(), + Ok(GeneratedTokenResult::ModelNotLoaded( + "agent: no model is loaded".to_owned() + )) + ); + } + + #[test] + fn rejects_an_embedding_batch_because_no_model_is_loaded() { + let (generated_embedding_tx, mut generated_embedding_rx) = mpsc::unbounded_channel(); + let (_generate_embedding_stop_tx, generate_embedding_stop_rx) = mpsc::unbounded_channel(); + + PipelineRequest::GenerateEmbeddingBatch(AgentRequest { + params: GenerateEmbeddingBatchParams { + input_batch: Vec::new(), + normalization_method: EmbeddingNormalizationMethod::None, + }, + response_tx: generated_embedding_tx, + slot_guard: SlotGuard::new(Arc::new(SlotAggregatedStatus::new(1))), + stop_rx: generate_embedding_stop_rx, + }) + .reject_because_no_model_is_loaded(Some("agent")); + + assert_eq!( + generated_embedding_rx.try_recv(), + Ok(EmbeddingResult::ModelNotLoaded( + "agent: no model is loaded".to_owned() + )) + ); + } + + fn decide_request( + decision_result_tx: mpsc::UnboundedSender, + ) -> PipelineRequest { + let (_decision_stop_tx, decision_stop_rx) = mpsc::unbounded_channel(); + + PipelineRequest::Decide(AgentRequest { + params: DecideParams { + last_question: DecisionQuestion { + id: "paid".to_owned(), + instructions: "Was it paid?".to_owned(), + options: vec!["no".to_owned(), "yes".to_owned()], + }, + leading_questions: Vec::new(), + state: "state".to_owned(), + }, + response_tx: decision_result_tx, + slot_guard: SlotGuard::new(Arc::new(SlotAggregatedStatus::new(1))), + stop_rx: decision_stop_rx, + }) + } + + #[test] + fn rejects_a_decision_because_no_model_is_loaded() { + let (decision_result_tx, mut decision_result_rx) = mpsc::unbounded_channel(); + + decide_request(decision_result_tx).reject_because_no_model_is_loaded(Some("agent")); + + assert_eq!( + decision_result_rx.try_recv(), + Ok(DecisionResult::ModelNotLoaded( + "agent: no model is loaded".to_owned() + )) + ); + } + + #[test] + fn rejects_a_decision_on_an_agent_serving_another_mode() { + let (decision_result_tx, mut decision_result_rx) = mpsc::unbounded_channel(); + + decide_request(decision_result_tx) + .reject_because_another_mode_is_served(Some("agent"), InferenceMode::Embeddings); + + assert!(matches!( + decision_result_rx.try_recv(), + Ok(DecisionResult::InferenceModeMismatch(_)) + )); + } + + #[test] + fn rejects_an_embedding_batch_on_an_agent_serving_another_mode() { + let (generated_embedding_tx, mut generated_embedding_rx) = mpsc::unbounded_channel(); + let (_generate_embedding_stop_tx, generate_embedding_stop_rx) = mpsc::unbounded_channel(); + + PipelineRequest::GenerateEmbeddingBatch(AgentRequest { + params: GenerateEmbeddingBatchParams { + input_batch: Vec::new(), + normalization_method: EmbeddingNormalizationMethod::None, + }, + response_tx: generated_embedding_tx, + slot_guard: SlotGuard::new(Arc::new(SlotAggregatedStatus::new(1))), + stop_rx: generate_embedding_stop_rx, + }) + .reject_because_another_mode_is_served(Some("agent"), InferenceMode::Decision); + + assert!(matches!( + generated_embedding_rx.try_recv(), + Ok(EmbeddingResult::InferenceModeMismatch(_)) + )); + } + + #[test] + fn rejects_token_generation_on_an_agent_serving_another_mode() { + let (generated_tokens_tx, mut generated_tokens_rx) = mpsc::unbounded_channel(); + let (_generate_tokens_stop_tx, generate_tokens_stop_rx) = mpsc::unbounded_channel(); + + PipelineRequest::ContinueFromRawPrompt(AgentRequest { + params: ContinueFromRawPromptParams { + grammar: None, + max_tokens: NonZeroU32::MIN, + raw_prompt: "Hello".to_owned(), + }, + response_tx: generated_tokens_tx, + slot_guard: SlotGuard::new(Arc::new(SlotAggregatedStatus::new(1))), + stop_rx: generate_tokens_stop_rx, + }) + .reject_because_another_mode_is_served(Some("agent"), InferenceMode::Decision); + + assert!(matches!( + generated_tokens_rx.try_recv(), + Ok(GeneratedTokenResult::InferenceModeMismatch(_)) + )); + } +} diff --git a/paddler_agent/src/prepared_embedding_batch_request.rs b/paddler_agent/src/prepared_embedding_batch_request.rs deleted file mode 100644 index eb3c42727..000000000 --- a/paddler_agent/src/prepared_embedding_batch_request.rs +++ /dev/null @@ -1,17 +0,0 @@ -use tokio::sync::mpsc; - -use paddler_messaging::embedding_normalization_method::EmbeddingNormalizationMethod; -use paddler_messaging::embedding_result::EmbeddingResult; -use paddler_messaging::oversized_embedding_document_details::OversizedEmbeddingDocumentDetails; - -use crate::embedding_input_tokenized::EmbeddingInputTokenized; -use crate::slot_guard::SlotGuard; - -pub struct PreparedEmbeddingBatchRequest { - pub generate_embedding_stop_rx: mpsc::UnboundedReceiver<()>, - pub generated_embedding_tx: mpsc::UnboundedSender, - pub inputs: Vec, - pub normalization_method: EmbeddingNormalizationMethod, - pub oversized_documents: Vec, - pub slot_guard: SlotGuard, -} diff --git a/paddler_agent/src/prepared_generation_request.rs b/paddler_agent/src/prepared_generation_request.rs deleted file mode 100644 index bec20fedc..000000000 --- a/paddler_agent/src/prepared_generation_request.rs +++ /dev/null @@ -1,22 +0,0 @@ -use std::num::NonZeroU32; - -use tokio::sync::mpsc; - -use paddler_messaging::generated_token_result::GeneratedTokenResult; - -use crate::prepared_prompt::PreparedPrompt; -use crate::slot_guard::SlotGuard; -use crate::token_classification::TokenClassification; -use crate::token_sampling::TokenSampling; -use crate::tool_call_handling::ToolCallHandling; - -pub struct PreparedGenerationRequest { - pub generate_tokens_stop_rx: mpsc::UnboundedReceiver<()>, - pub generated_tokens_tx: mpsc::UnboundedSender, - pub max_tokens: NonZeroU32, - pub prompt: PreparedPrompt, - pub slot_guard: SlotGuard, - pub token_classification: TokenClassification, - pub token_sampling: TokenSampling, - pub tool_call_handling: ToolCallHandling, -} diff --git a/paddler_agent/src/reconciliation_service.rs b/paddler_agent/src/reconciliation_service.rs index a37bc7b9e..b05c2b497 100644 --- a/paddler_agent/src/reconciliation_service.rs +++ b/paddler_agent/src/reconciliation_service.rs @@ -72,10 +72,11 @@ mod tests { use tokio_util::sync::CancellationToken; use paddler_agent_status::slot_aggregated_status::SlotAggregatedStatus; - use paddler_inference_parameters::inference_parameters::InferenceParameters; use paddler_messaging::agent_desired_model::AgentDesiredModel; use paddler_messaging::agent_desired_state::AgentDesiredState; + use paddler_messaging::balancer_desired_state::BalancerDesiredState; use paddler_messaging::huggingface_model_reference::HuggingFaceModelReference; + use paddler_messaging::inference_mode::InferenceMode; use crate::agent_applicable_state::AgentApplicableState; use crate::agent_applicable_state_holder::AgentApplicableStateHolder; @@ -84,10 +85,10 @@ mod tests { fn desired_state_with_model(model: AgentDesiredModel) -> AgentDesiredState { AgentDesiredState { - chat_template_override: None, - inference_parameters: InferenceParameters::default(), model, - multimodal_projection: AgentDesiredModel::None, + ..AgentDesiredState::from(BalancerDesiredState::unconfigured( + InferenceMode::TextGeneration, + )) } } diff --git a/paddler_agent/src/require_embeddings_enabled.rs b/paddler_agent/src/require_embeddings_enabled.rs deleted file mode 100644 index 2328b08f8..000000000 --- a/paddler_agent/src/require_embeddings_enabled.rs +++ /dev/null @@ -1,32 +0,0 @@ -use crate::embedding_batch_rejection::EmbeddingBatchRejection; - -pub const fn require_embeddings_enabled( - enable_embeddings: bool, -) -> Result<(), EmbeddingBatchRejection> { - if enable_embeddings { - Ok(()) - } else { - Err(EmbeddingBatchRejection::EmbeddingsDisabled) - } -} - -#[cfg(test)] -mod tests { - use std::mem::discriminant; - - use super::require_embeddings_enabled; - use crate::embedding_batch_rejection::EmbeddingBatchRejection; - - #[test] - fn accepts_batches_when_embeddings_are_enabled() { - assert!(require_embeddings_enabled(true).is_ok()); - } - - #[test] - fn rejects_batches_when_embeddings_are_disabled() { - assert!( - require_embeddings_enabled(false).is_err_and(|rejection| discriminant(&rejection) - == discriminant(&EmbeddingBatchRejection::EmbeddingsDisabled)) - ); - } -} diff --git a/paddler_agent/src/running_pipeline.rs b/paddler_agent/src/running_pipeline.rs new file mode 100644 index 000000000..b27aac0d2 --- /dev/null +++ b/paddler_agent/src/running_pipeline.rs @@ -0,0 +1,70 @@ +use paddler_agent_decision::decision_error::DecisionError; +use paddler_agent_decision::decision_request_preparer::DecisionRequestPreparer; +use paddler_agent_embeddings::embedding_batch_preparer::EmbeddingBatchPreparer; +use paddler_agent_embeddings::embedding_error::EmbeddingError; +use paddler_agent_runtime::scheduler_handle::SchedulerHandle; +use paddler_agent_text_generation::generation_request_preparer::GenerationRequestPreparer; +use paddler_agent_text_generation::text_generation_error::TextGenerationError; +use paddler_agent_text_generation::text_generation_request::TextGenerationRequest; +use paddler_messaging::inference_mode::InferenceMode; + +use crate::agent_error::AgentError; +use crate::pipeline_request::PipelineRequest; + +pub enum RunningPipeline { + Decision(SchedulerHandle), + Embeddings(SchedulerHandle), + TextGeneration(SchedulerHandle), +} + +impl RunningPipeline { + pub fn forward(&self, agent_name: Option<&str>, request: PipelineRequest) { + match (self, request) { + (Self::Decision(scheduler_handle), PipelineRequest::Decide(request)) => { + scheduler_handle.request_preparer.prepare(request); + } + ( + Self::Embeddings(scheduler_handle), + PipelineRequest::GenerateEmbeddingBatch(request), + ) => { + scheduler_handle.request_preparer.prepare(request); + } + ( + Self::TextGeneration(scheduler_handle), + PipelineRequest::ContinueFromConversationHistory(request), + ) => { + scheduler_handle.request_preparer.prepare( + TextGenerationRequest::ContinueFromConversationHistory(request), + ); + } + ( + Self::TextGeneration(scheduler_handle), + PipelineRequest::ContinueFromRawPrompt(request), + ) => { + scheduler_handle + .request_preparer + .prepare(TextGenerationRequest::ContinueFromRawPrompt(request)); + } + (running_pipeline, request) => request.reject_because_another_mode_is_served( + agent_name, + running_pipeline.inference_mode(), + ), + } + } + + pub async fn shut_down(self) -> Result<(), AgentError> { + match self { + Self::Decision(scheduler_handle) => Ok(scheduler_handle.shut_down().await?), + Self::Embeddings(scheduler_handle) => Ok(scheduler_handle.shut_down().await?), + Self::TextGeneration(scheduler_handle) => Ok(scheduler_handle.shut_down().await?), + } + } + + const fn inference_mode(&self) -> InferenceMode { + match self { + Self::Decision(_) => InferenceMode::Decision, + Self::Embeddings(_) => InferenceMode::Embeddings, + Self::TextGeneration(_) => InferenceMode::TextGeneration, + } + } +} diff --git a/paddler_agent/src/token_generation.rs b/paddler_agent/src/token_generation.rs deleted file mode 100644 index d196525f3..000000000 --- a/paddler_agent/src/token_generation.rs +++ /dev/null @@ -1,39 +0,0 @@ -use crate::generation_request_rejection::GenerationRequestRejection; -use crate::token_generation_support::TokenGenerationSupport; - -pub enum TokenGeneration { - DisabledForEmbeddings, - Enabled(Box), -} - -impl TokenGeneration { - pub const fn require_enabled( - &self, - ) -> Result<&TokenGenerationSupport, GenerationRequestRejection> { - match self { - Self::DisabledForEmbeddings => Err(GenerationRequestRejection::TokenGenerationDisabled), - Self::Enabled(token_generation_support) => Ok(token_generation_support), - } - } -} - -#[cfg(test)] -mod tests { - use std::mem::discriminant; - - use super::TokenGeneration; - use crate::generation_request_rejection::GenerationRequestRejection; - - #[test] - fn rejects_generation_in_embeddings_mode() { - assert_eq!( - TokenGeneration::DisabledForEmbeddings - .require_enabled() - .err() - .map(|rejection| discriminant(&rejection)), - Some(discriminant( - &GenerationRequestRejection::TokenGenerationDisabled - )) - ); - } -} diff --git a/paddler_agent_decision/Cargo.toml b/paddler_agent_decision/Cargo.toml new file mode 100644 index 000000000..7e24444ee --- /dev/null +++ b/paddler_agent_decision/Cargo.toml @@ -0,0 +1,31 @@ +[package] +name = "paddler_agent_decision" +authors.workspace = true +description.workspace = true +edition.workspace = true +homepage.workspace = true +license.workspace = true +repository.workspace = true +version.workspace = true + +[dependencies] +icu_normalizer = { workspace = true } +llama-cpp-bindings = { workspace = true } +log = { workspace = true } +thiserror = { workspace = true } +tokio = { workspace = true } +tokio-util = { workspace = true } +paddler_agent_pointer_head = { workspace = true } +paddler_agent_runtime = { workspace = true } +paddler_agent_status = { workspace = true } +paddler_inference_parameters = { workspace = true } +paddler_messaging = { workspace = true } + +[features] +default = [] +cuda = ["llama-cpp-bindings/cuda", "paddler_agent_pointer_head/cuda", "paddler_agent_runtime/cuda"] +metal = ["llama-cpp-bindings/metal", "paddler_agent_pointer_head/metal", "paddler_agent_runtime/metal"] +vulkan = ["llama-cpp-bindings/vulkan", "paddler_agent_pointer_head/vulkan", "paddler_agent_runtime/vulkan"] + +[lints] +workspace = true diff --git a/paddler_agent_decision/src/active_decision_request.rs b/paddler_agent_decision/src/active_decision_request.rs new file mode 100644 index 000000000..7a50c75e3 --- /dev/null +++ b/paddler_agent_decision/src/active_decision_request.rs @@ -0,0 +1,230 @@ +use std::collections::VecDeque; +use std::time::Instant; + +use llama_cpp_bindings::token::LlamaToken; +use tokio::sync::mpsc; + +use paddler_agent_pointer_head::pointer_head::PointerHead; +use paddler_agent_runtime::receives_stop_request::ReceivesStopRequest as _; +use paddler_agent_runtime::sequence_id_guard::SequenceIdGuard; +use paddler_agent_status::slot_guard::SlotGuard; +use paddler_messaging::decision_answer::DecisionAnswer; +use paddler_messaging::decision_result::DecisionResult; +use paddler_messaging::decision_summary::DecisionSummary; + +use crate::decision_admission::DecisionAdmission; +use crate::decision_advance::DecisionAdvance; +use crate::decision_decoder::DecisionDecoder; +use crate::decision_error::DecisionError; +use crate::decision_question_row::DecisionQuestionRow; +use crate::decision_request_phase::DecisionRequestPhase; +use crate::decision_token_layout::DecisionTokenLayout; +use crate::prepared_decision_request::PreparedDecisionRequest; +use crate::promised_decision_cells::PromisedDecisionCells; +use crate::question_lane::QuestionLane; + +pub struct ActiveDecisionRequest { + pub current_question: DecisionQuestionRow, + pub decision_result_tx: mpsc::UnboundedSender, + pub decision_stop_rx: mpsc::UnboundedReceiver<()>, + pub hidden_states: Vec>, + pub input_tokens: usize, + pub phase: DecisionRequestPhase, + pub promised_cells: PromisedDecisionCells, + pub question_lane: QuestionLane, + pub remaining_questions: VecDeque, + pub slot_guard: SlotGuard, + pub started_at: Instant, + pub state: Vec, + pub state_sequence: SequenceIdGuard, +} + +impl ActiveDecisionRequest { + #[must_use] + pub fn new( + PreparedDecisionRequest { + decision_result_tx, + decision_stop_rx, + layout, + slot_guard, + started_at, + }: PreparedDecisionRequest, + DecisionAdmission { + promised_cells, + question_lane, + state_sequence, + }: DecisionAdmission, + ) -> Self { + let input_tokens = layout.total_tokens(); + let DecisionTokenLayout { + first_question, + remaining_questions, + state, + } = layout; + + Self { + current_question: first_question, + decision_result_tx, + decision_stop_rx, + hidden_states: Vec::new(), + input_tokens, + phase: DecisionRequestPhase::IngestingState { next_offset: 0 }, + promised_cells, + question_lane, + remaining_questions, + slot_guard, + started_at, + state, + state_sequence, + } + } + + pub fn advance( + &mut self, + decoder: &mut DecisionDecoder, + pointer_head: &PointerHead, + ) -> Result { + match self.phase { + DecisionRequestPhase::IngestingState { next_offset } => { + let next_offset = decoder.ingest_state_chunk( + self.state_sequence.sequence_id(), + &self.state, + next_offset, + )?; + + self.phase = if next_offset < self.state.len() { + DecisionRequestPhase::IngestingState { next_offset } + } else { + DecisionRequestPhase::AnsweringQuestion { next_offset: 0 } + }; + + Ok(DecisionAdvance::Continuing) + } + DecisionRequestPhase::AnsweringQuestion { next_offset } => match &self.question_lane { + QuestionLane::Reserved(lane) => { + let lane_sequence_id = lane.sequence_id(); + + self.answer_leading_question( + decoder, + pointer_head, + lane_sequence_id, + next_offset, + ) + } + QuestionLane::Released => { + self.answer_last_question(decoder, pointer_head, next_offset) + } + }, + } + } + + pub fn is_stop_requested(&mut self) -> bool { + self.decision_stop_rx.is_stop_requested() + } + + pub fn release_sequences(&self, decoder: &mut DecisionDecoder) -> Result<(), DecisionError> { + decoder.remove_sequence(self.state_sequence.sequence_id())?; + + if let QuestionLane::Reserved(lane) = &self.question_lane { + decoder.remove_sequence(lane.sequence_id())?; + } + + Ok(()) + } + + #[must_use] + pub fn summary(&self) -> DecisionResult { + DecisionResult::Done(DecisionSummary { + input_tokens: self.input_tokens, + processing_milliseconds: self.started_at.elapsed().as_millis() as u64, + }) + } + + fn answer_last_question( + &mut self, + decoder: &mut DecisionDecoder, + pointer_head: &PointerHead, + next_offset: usize, + ) -> Result { + let next_offset = + self.decode_question_chunk(decoder, self.state_sequence.sequence_id(), next_offset)?; + + if next_offset < self.current_question.tokens.len() { + self.phase = DecisionRequestPhase::AnsweringQuestion { next_offset }; + + return Ok(DecisionAdvance::Continuing); + } + + self.send_answer(pointer_head)?; + + Ok(self.finish_question()) + } + + fn answer_leading_question( + &mut self, + decoder: &mut DecisionDecoder, + pointer_head: &PointerHead, + lane_sequence_id: i32, + next_offset: usize, + ) -> Result { + if next_offset == 0 { + decoder.copy_sequence(self.state_sequence.sequence_id(), lane_sequence_id)?; + } + + let next_offset = self.decode_question_chunk(decoder, lane_sequence_id, next_offset)?; + + if next_offset < self.current_question.tokens.len() { + self.phase = DecisionRequestPhase::AnsweringQuestion { next_offset }; + + return Ok(DecisionAdvance::Continuing); + } + + self.send_answer(pointer_head)?; + decoder.remove_sequence(lane_sequence_id)?; + + Ok(self.finish_question()) + } + + fn decode_question_chunk( + &mut self, + decoder: &mut DecisionDecoder, + sequence_id: i32, + next_offset: usize, + ) -> Result { + decoder.decode_row_chunk( + sequence_id, + &self.current_question, + next_offset, + self.state.len(), + &mut self.hidden_states, + ) + } + + fn finish_question(&mut self) -> DecisionAdvance { + let Some(next_question) = self.remaining_questions.pop_front() else { + return DecisionAdvance::Completed; + }; + + self.current_question = next_question; + + if self.remaining_questions.is_empty() { + self.question_lane = QuestionLane::Released; + } + + self.phase = DecisionRequestPhase::AnsweringQuestion { next_offset: 0 }; + + DecisionAdvance::Continuing + } + + fn send_answer(&mut self, pointer_head: &PointerHead) -> Result<(), DecisionError> { + let probabilities = pointer_head.option_probabilities(&self.hidden_states); + + self.hidden_states.clear(); + self.decision_result_tx + .send(DecisionResult::QuestionAnswered(DecisionAnswer { + id: self.current_question.id.clone(), + probabilities, + })) + .map_err(DecisionError::ClientDisconnected) + } +} diff --git a/paddler_agent_decision/src/decision_admission.rs b/paddler_agent_decision/src/decision_admission.rs new file mode 100644 index 000000000..f7fe810a6 --- /dev/null +++ b/paddler_agent_decision/src/decision_admission.rs @@ -0,0 +1,10 @@ +use paddler_agent_runtime::sequence_id_guard::SequenceIdGuard; + +use crate::promised_decision_cells::PromisedDecisionCells; +use crate::question_lane::QuestionLane; + +pub struct DecisionAdmission { + pub promised_cells: PromisedDecisionCells, + pub question_lane: QuestionLane, + pub state_sequence: SequenceIdGuard, +} diff --git a/paddler_agent_decision/src/decision_advance.rs b/paddler_agent_decision/src/decision_advance.rs new file mode 100644 index 000000000..a64b25873 --- /dev/null +++ b/paddler_agent_decision/src/decision_advance.rs @@ -0,0 +1,4 @@ +pub enum DecisionAdvance { + Completed, + Continuing, +} diff --git a/paddler_agent_decision/src/decision_capacity_ledger.rs b/paddler_agent_decision/src/decision_capacity_ledger.rs new file mode 100644 index 000000000..23cfc3832 --- /dev/null +++ b/paddler_agent_decision/src/decision_capacity_ledger.rs @@ -0,0 +1,125 @@ +use std::cell::Cell; +use std::rc::Rc; + +use paddler_agent_runtime::sequence_id_guard::SequenceIdGuard; +use paddler_agent_runtime::sequence_id_pool::SequenceIdPool; + +use crate::decision_admission::DecisionAdmission; +use crate::decision_token_layout::DecisionTokenLayout; +use crate::promised_decision_cells::PromisedDecisionCells; +use crate::question_lane::QuestionLane; + +pub struct DecisionCapacityLedger { + available_cells: Rc>, + sequence_id_pool: SequenceIdPool, +} + +impl DecisionCapacityLedger { + #[must_use] + pub fn new(context_cells: usize, sequences: u16) -> Self { + Self { + available_cells: Rc::new(Cell::new(context_cells)), + sequence_id_pool: SequenceIdPool::new(sequences), + } + } + + #[must_use] + pub fn admit(&self, layout: &DecisionTokenLayout) -> Option { + let required_cells = layout.required_cells(); + + if self.available_cells.get() < required_cells { + return None; + } + + let state_sequence = SequenceIdGuard::acquire(&self.sequence_id_pool)?; + let question_lane = if layout.needs_question_lane() { + QuestionLane::Reserved(SequenceIdGuard::acquire(&self.sequence_id_pool)?) + } else { + QuestionLane::Released + }; + + self.available_cells + .set(self.available_cells.get() - required_cells); + + Some(DecisionAdmission { + promised_cells: PromisedDecisionCells { + available_cells: self.available_cells.clone(), + cells: required_cells, + }, + question_lane, + state_sequence, + }) + } +} + +#[cfg(test)] +mod tests { + use std::collections::VecDeque; + + use llama_cpp_bindings::token::LlamaToken; + + use super::DecisionCapacityLedger; + use crate::decision_question_row::DecisionQuestionRow; + use crate::decision_token_layout::DecisionTokenLayout; + + fn question_row(row_tokens: i32) -> DecisionQuestionRow { + DecisionQuestionRow { + id: format!("question-{row_tokens}"), + option_end_offsets: Vec::new(), + tokens: (0..row_tokens).map(LlamaToken::new).collect(), + } + } + + fn layout(state_tokens: i32, question_row_tokens: &[i32]) -> DecisionTokenLayout { + let mut remaining_questions: VecDeque = question_row_tokens + .iter() + .copied() + .map(question_row) + .collect(); + + DecisionTokenLayout { + first_question: remaining_questions + .pop_front() + .expect("a test layout has at least one question"), + remaining_questions, + state: (0..state_tokens).map(LlamaToken::new).collect(), + } + } + + #[test] + fn a_single_question_decision_takes_only_its_state_sequence() { + let ledger = DecisionCapacityLedger::new(100, 2); + let _first_admission = ledger + .admit(&layout(10, &[5])) + .expect("one free sequence fits a single question"); + + assert!(ledger.admit(&layout(10, &[5])).is_some()); + } + + #[test] + fn a_multi_question_decision_waits_for_a_free_question_lane() { + let ledger = DecisionCapacityLedger::new(100, 1); + + assert!(ledger.admit(&layout(10, &[5, 5])).is_none()); + } + + #[test] + fn a_decision_waits_while_every_sequence_is_taken() { + let ledger = DecisionCapacityLedger::new(100, 2); + let _first_admission = ledger.admit(&layout(10, &[5, 5])); + + assert!(ledger.admit(&layout(10, &[5])).is_none()); + } + + #[test] + fn a_decision_waits_until_its_promised_cells_fit_and_runs_once_they_are_returned() { + let ledger = DecisionCapacityLedger::new(20, 4); + let first_admission = ledger.admit(&layout(10, &[5])); + + assert!(ledger.admit(&layout(5, &[3])).is_none()); + + drop(first_admission); + + assert!(ledger.admit(&layout(5, &[3])).is_some()); + } +} diff --git a/paddler_agent_decision/src/decision_decoder.rs b/paddler_agent_decision/src/decision_decoder.rs new file mode 100644 index 000000000..b24d2156b --- /dev/null +++ b/paddler_agent_decision/src/decision_decoder.rs @@ -0,0 +1,122 @@ +use llama_cpp_bindings::SampledToken; +use llama_cpp_bindings::context::LlamaContext; +use llama_cpp_bindings::llama_batch::LlamaBatch; +use llama_cpp_bindings::token::LlamaToken; + +use crate::decision_error::DecisionError; +use crate::decision_question_row::DecisionQuestionRow; + +#[expect( + clippy::cast_possible_wrap, + reason = "positions stay below the context size the preparer checks, and llama.cpp keeps contexts within i32 positions" +)] +const fn token_position(position: usize) -> i32 { + position as i32 +} + +pub struct DecisionDecoder<'scheduler, 'model> { + pub batch: &'scheduler mut LlamaBatch<'static>, + pub llama_context: &'scheduler mut LlamaContext<'model>, + pub n_batch: usize, +} + +impl DecisionDecoder<'_, '_> { + pub fn copy_sequence( + &mut self, + source_sequence_id: i32, + destination_sequence_id: i32, + ) -> Result<(), DecisionError> { + self.remove_sequence(destination_sequence_id)?; + self.llama_context + .copy_kv_cache_seq(source_sequence_id, destination_sequence_id, None, None) + .map_err(DecisionError::KvCacheCopyFailed) + } + + pub fn ingest_state_chunk( + &mut self, + sequence_id: i32, + state: &[LlamaToken], + next_offset: usize, + ) -> Result { + self.batch.clear(); + + for (position, token) in state + .iter() + .enumerate() + .skip(next_offset) + .take(self.n_batch) + { + self.batch + .add( + &SampledToken::Content(*token), + token_position(position), + &[sequence_id], + false, + ) + .map_err(DecisionError::BatchAssemblyFailed)?; + } + + self.llama_context + .decode(self.batch) + .map_err(DecisionError::DecodeFailed)?; + + Ok(next_offset + self.batch.n_tokens() as usize) + } + + pub fn decode_row_chunk( + &mut self, + sequence_id: i32, + row: &DecisionQuestionRow, + next_offset: usize, + state_length: usize, + hidden_states: &mut Vec>, + ) -> Result { + self.batch.clear(); + + let mut output_batch_indices = Vec::new(); + + for (batch_index, (row_offset, token)) in (0_i32..).zip( + row.tokens + .iter() + .enumerate() + .skip(next_offset) + .take(self.n_batch), + ) { + let is_output = row.is_output(row_offset); + + self.batch + .add( + &SampledToken::Content(*token), + token_position(state_length + row_offset), + &[sequence_id], + is_output, + ) + .map_err(DecisionError::BatchAssemblyFailed)?; + + if is_output { + output_batch_indices.push(batch_index); + } + } + + self.llama_context + .decode(self.batch) + .map_err(DecisionError::DecodeFailed)?; + + for batch_index in output_batch_indices { + hidden_states.push( + self.llama_context + .nextn_embeddings_ith(batch_index) + .map_err(DecisionError::HiddenStateUnavailable)? + .to_vec(), + ); + } + + Ok(next_offset + self.batch.n_tokens() as usize) + } + + pub fn remove_sequence(&mut self, sequence_id: i32) -> Result<(), DecisionError> { + self.llama_context + .clear_kv_cache_seq(Some(sequence_id as u32), None, None) + .map_err(DecisionError::KvCacheRemovalFailed) + } +} diff --git a/paddler_agent_decision/src/decision_delimiter_tokens.rs b/paddler_agent_decision/src/decision_delimiter_tokens.rs new file mode 100644 index 000000000..312416d73 --- /dev/null +++ b/paddler_agent_decision/src/decision_delimiter_tokens.rs @@ -0,0 +1,79 @@ +use llama_cpp_bindings::llama_token_attr::LlamaTokenAttr; +use llama_cpp_bindings::model::AddBos; +use llama_cpp_bindings::model::LlamaModel; +use llama_cpp_bindings::model::ParseSpecialTokens; +use llama_cpp_bindings::token::LlamaToken; + +use paddler_agent_pointer_head::pointer_head_delimiters::PointerHeadDelimiters; +use paddler_messaging::agent_issue_params::pointer_head_incompatibility::PointerHeadIncompatibility; + +use crate::decision_error::DecisionError; + +fn resolve_control_token(model: &LlamaModel, delimiter: &str) -> Result { + let not_a_control_token = || DecisionError::PointerHeadIncompatibleWithModel { + incompatibility: PointerHeadIncompatibility::DelimiterIsNotAControlToken { + delimiter: delimiter.to_owned(), + }, + }; + let tokens = model + .str_to_token(delimiter, AddBos::Never, ParseSpecialTokens::Always) + .map_err(|source| DecisionError::DelimiterTokenizationFailed { + delimiter: delimiter.to_owned(), + source, + })?; + let control_token = match tokens.as_slice() { + [token] => model + .token_attr(*token) + .map_err(|source| DecisionError::DelimiterAttributesUnreadable { + delimiter: delimiter.to_owned(), + source, + })? + .contains(LlamaTokenAttr::Control) + .then_some(*token), + _ => None, + }; + + control_token.ok_or_else(not_a_control_token) +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct DecisionDelimiterTokens { + pub decide: LlamaToken, + pub option_end: LlamaToken, + pub option_start: LlamaToken, + pub question: LlamaToken, + pub state: LlamaToken, +} + +impl DecisionDelimiterTokens { + pub fn resolve( + model: &LlamaModel, + PointerHeadDelimiters { + decide, + option_end, + option_start, + question, + state, + }: &PointerHeadDelimiters, + ) -> Result { + let mut control_tokens = [LlamaToken::new(0); 5]; + + for (control_token, delimiter) in + control_tokens + .iter_mut() + .zip([decide, option_end, option_start, question, state]) + { + *control_token = resolve_control_token(model, delimiter)?; + } + + let [decide, option_end, option_start, question, state] = control_tokens; + + Ok(Self { + decide, + option_end, + option_start, + question, + state, + }) + } +} diff --git a/paddler_agent_decision/src/decision_error.rs b/paddler_agent_decision/src/decision_error.rs new file mode 100644 index 000000000..e5fe2be1d --- /dev/null +++ b/paddler_agent_decision/src/decision_error.rs @@ -0,0 +1,250 @@ +use llama_cpp_bindings::batch_add_error::BatchAddError; +use llama_cpp_bindings::error::ClearKvCacheSeqError; +use llama_cpp_bindings::error::CopyKvCacheSeqError; +use llama_cpp_bindings::error::DecodeError; +use llama_cpp_bindings::error::EmbeddingsError; +use llama_cpp_bindings::error::MetaValError; +use llama_cpp_bindings::error::StringToTokenError; +use llama_cpp_bindings::llama_token_attrs_from_int_error::LlamaTokenAttrsFromIntError; +use log::error; +use tokio::sync::mpsc; +use tokio::sync::mpsc::error::SendError; + +use paddler_agent_pointer_head::pointer_head_error::PointerHeadError; +use paddler_agent_runtime::agent_runtime_error::AgentRuntimeError; +use paddler_agent_runtime::rejection_description::rejection_description; +use paddler_agent_runtime::send_result_or_warn::send_result_or_warn; +use paddler_messaging::agent_issue_params::pointer_head_incompatibility::PointerHeadIncompatibility; +use paddler_messaging::decision_result::DecisionResult; +use paddler_messaging::inference_mode::InferenceMode; +use paddler_messaging::oversized_decision_details::OversizedDecisionDetails; + +#[derive(Debug, thiserror::Error)] +pub enum DecisionError { + #[error(transparent)] + AgentRuntime(#[from] AgentRuntimeError), + + #[error("failed to add a decision token to the batch: {0}")] + BatchAssemblyFailed(#[source] BatchAddError), + + #[error("the client stopped listening for decisions: {0}")] + ClientDisconnected(#[source] SendError), + + #[error("llama.cpp rejected the decision batch: {0}")] + DecodeFailed(#[source] DecodeError), + + #[error("unable to read the attributes of the pointer head delimiter {delimiter}: {source}")] + DelimiterAttributesUnreadable { + delimiter: String, + #[source] + source: LlamaTokenAttrsFromIntError, + }, + + #[error("unable to tokenize the pointer head delimiter {delimiter}: {source}")] + DelimiterTokenizationFailed { + delimiter: String, + #[source] + source: StringToTokenError, + }, + + #[error("unable to read the hidden state of a decision output: {0}")] + HiddenStateUnavailable(#[source] EmbeddingsError), + + #[error("unable to expose the hidden states decisions read: {0}")] + HiddenStatesUnsupported(#[source] EmbeddingsError), + + #[error("the agent serves {serving_inference_mode:?}, so it cannot decide")] + InferenceModeMismatch { + serving_inference_mode: InferenceMode, + }, + + #[error("failed to tokenize the decision input: {0}")] + InputTokenizationFailed(#[source] StringToTokenError), + + #[error("failed to copy the decision state into its question lane: {0}")] + KvCacheCopyFailed(#[source] CopyKvCacheSeqError), + + #[error("failed to remove a decision sequence from the KV cache: {0}")] + KvCacheRemovalFailed(#[source] ClearKvCacheSeqError), + + #[error("unable to read the model architecture: {0}")] + ModelArchitectureUnreadable(#[source] MetaValError), + + #[error("{architecture} models cannot serve decisions")] + ModelArchitectureUnsupported { architecture: String }, + + #[error("no model is loaded")] + ModelNotLoaded, + + #[error("unable to load the pointer head: {0}")] + PointerHeadCannotBeLoaded(#[source] PointerHeadError), + + #[error("the pointer head does not fit the model: {incompatibility:?}")] + PointerHeadIncompatibleWithModel { + incompatibility: PointerHeadIncompatibility, + }, + + #[error( + "the decision needs {} tokens in the cache, more than the {} the context holds", + details.required_tokens, + details.context_size + )] + RequestExceedsContext { details: OversizedDecisionDetails }, + + #[error("the scheduler is no longer accepting requests")] + SchedulerUnavailable, + + #[error("decisions need {required_slots} slots, the agent runs {desired_slots}")] + SlotsInsufficient { + desired_slots: u16, + required_slots: u16, + }, +} + +impl DecisionError { + pub fn report( + self, + agent_name: Option<&str>, + decision_result_tx: &mpsc::UnboundedSender, + ) { + let message = rejection_description(agent_name, &self); + + error!("{message}"); + + let result = match self { + Self::AgentRuntime(_) + | Self::DelimiterAttributesUnreadable { .. } + | Self::DelimiterTokenizationFailed { .. } + | Self::HiddenStatesUnsupported(_) + | Self::ModelArchitectureUnreadable(_) + | Self::ModelArchitectureUnsupported { .. } + | Self::ModelNotLoaded + | Self::PointerHeadCannotBeLoaded(_) + | Self::PointerHeadIncompatibleWithModel { .. } + | Self::SlotsInsufficient { .. } => DecisionResult::ModelNotLoaded(message), + Self::BatchAssemblyFailed(_) => DecisionResult::BatchAssemblyFailed(message), + Self::ClientDisconnected(_) => return, + Self::DecodeFailed(_) => DecisionResult::DecodeFailed(message), + Self::HiddenStateUnavailable(_) => DecisionResult::HiddenStateUnavailable(message), + Self::InferenceModeMismatch { .. } => DecisionResult::InferenceModeMismatch(message), + Self::InputTokenizationFailed(_) => DecisionResult::InputTokenizationFailed(message), + Self::KvCacheCopyFailed(_) => DecisionResult::KvCacheCopyFailed(message), + Self::KvCacheRemovalFailed(_) => DecisionResult::KvCacheRemovalFailed(message), + Self::RequestExceedsContext { details } => { + DecisionResult::RequestExceedsContext(details) + } + Self::SchedulerUnavailable => DecisionResult::SchedulerUnavailable(message), + }; + + send_result_or_warn(agent_name, decision_result_tx, result); + } +} + +#[cfg(test)] +mod tests { + use std::ffi::CString; + use std::mem::discriminant; + + use llama_cpp_bindings::batch_add_error::BatchAddError; + use llama_cpp_bindings::error::ClearKvCacheSeqError; + use llama_cpp_bindings::error::CopyKvCacheSeqError; + use llama_cpp_bindings::error::DecodeError; + use llama_cpp_bindings::error::EmbeddingsError; + use llama_cpp_bindings::error::StringToTokenError; + use tokio::sync::mpsc; + use tokio::sync::mpsc::error::SendError; + + use paddler_messaging::decision_result::DecisionResult; + use paddler_messaging::inference_mode::InferenceMode; + use paddler_messaging::oversized_decision_details::OversizedDecisionDetails; + + use super::DecisionError; + + fn reported(decision_error: DecisionError) -> Option { + let (decision_result_tx, mut decision_result_rx) = mpsc::unbounded_channel(); + + decision_error.report(None, &decision_result_tx); + + decision_result_rx.try_recv().ok() + } + + #[test] + fn every_request_failure_reports_its_own_decision_result() { + let description = String::new; + let oversized = OversizedDecisionDetails { + context_size: 256, + required_tokens: 900, + }; + let failures = [ + ( + DecisionError::BatchAssemblyFailed(BatchAddError::InsufficientSpace(1)), + DecisionResult::BatchAssemblyFailed(description()), + ), + ( + DecisionError::DecodeFailed(DecodeError::NoKvCacheSlot), + DecisionResult::DecodeFailed(description()), + ), + ( + DecisionError::HiddenStateUnavailable(EmbeddingsError::NextnEmbeddingsNotEnabled), + DecisionResult::HiddenStateUnavailable(description()), + ), + ( + DecisionError::InferenceModeMismatch { + serving_inference_mode: InferenceMode::Embeddings, + }, + DecisionResult::InferenceModeMismatch(description()), + ), + ( + DecisionError::InputTokenizationFailed(StringToTokenError::NulError( + CString::new("a \0 byte").unwrap_err(), + )), + DecisionResult::InputTokenizationFailed(description()), + ), + ( + DecisionError::KvCacheCopyFailed(CopyKvCacheSeqError::MemoryHandleUnavailable), + DecisionResult::KvCacheCopyFailed(description()), + ), + ( + DecisionError::KvCacheRemovalFailed( + ClearKvCacheSeqError::PartialSequenceNotRemoved { + seq_id: 0, + p0: -1, + p1: -1, + }, + ), + DecisionResult::KvCacheRemovalFailed(description()), + ), + ( + DecisionError::ModelNotLoaded, + DecisionResult::ModelNotLoaded(description()), + ), + ( + DecisionError::RequestExceedsContext { + details: oversized.clone(), + }, + DecisionResult::RequestExceedsContext(oversized), + ), + ( + DecisionError::SchedulerUnavailable, + DecisionResult::SchedulerUnavailable(description()), + ), + ]; + + for (decision_error, expected_result) in failures { + assert_eq!( + reported(decision_error).as_ref().map(discriminant), + Some(discriminant(&expected_result)) + ); + } + } + + #[test] + fn a_disconnected_client_is_not_sent_a_result() { + assert_eq!( + reported(DecisionError::ClientDisconnected(SendError( + DecisionResult::SchedulerUnavailable(String::new()) + ))), + None + ); + } +} diff --git a/paddler_agent_decision/src/decision_pipeline.rs b/paddler_agent_decision/src/decision_pipeline.rs new file mode 100644 index 000000000..7e21951fb --- /dev/null +++ b/paddler_agent_decision/src/decision_pipeline.rs @@ -0,0 +1,262 @@ +use std::collections::VecDeque; +use std::path::PathBuf; +use std::sync::Arc; +use std::sync::mpsc::channel; +use std::thread; + +use llama_cpp_bindings::llama_batch::LlamaBatch; +use llama_cpp_bindings::model::params::LlamaModelParams; +use tokio::sync::oneshot; +use tokio_util::sync::CancellationToken; +use tokio_util::task::TaskTracker; + +use paddler_agent_pointer_head::pointer_head::PointerHead; +use paddler_agent_runtime::agent_runtime_error::AgentRuntimeError; +use paddler_agent_runtime::await_scheduler_startup::await_scheduler_startup; +use paddler_agent_runtime::inference_runtime_context::InferenceRuntimeContext; +use paddler_agent_runtime::inference_thread_count::inference_thread_count; +use paddler_agent_runtime::llama_context_settings::LlamaContextSettings; +use paddler_agent_runtime::loaded_llama_model::LoadedLlamaModel; +use paddler_agent_runtime::scheduler_request_preparer::SchedulerRequestPreparer; +use paddler_agent_runtime::scheduler_spawn_outcome::SchedulerSpawnOutcome; +use paddler_agent_runtime::send_startup_signal::send_startup_signal; +use paddler_agent_runtime::startup_signal_delivery::StartupSignalDelivery; +use paddler_inference_parameters::model_runtime_parameters::ModelRuntimeParameters; +use paddler_messaging::agent_issue::AgentIssue; +use paddler_messaging::agent_issue_params::model_architecture_unsupported_for_decisions_params::ModelArchitectureUnsupportedForDecisionsParams; +use paddler_messaging::agent_issue_params::model_path::ModelPath; +use paddler_messaging::agent_issue_params::pointer_head_incompatibility::PointerHeadIncompatibility; +use paddler_messaging::agent_issue_params::pointer_head_incompatible_with_model_params::PointerHeadIncompatibleWithModelParams; +use paddler_messaging::agent_issue_params::slots_insufficient_for_decisions_params::SlotsInsufficientForDecisionsParams; + +use crate::decision_capacity_ledger::DecisionCapacityLedger; +use crate::decision_delimiter_tokens::DecisionDelimiterTokens; +use crate::decision_error::DecisionError; +use crate::decision_request_preparer::DecisionRequestPreparer; +use crate::decision_scheduler::DecisionScheduler; +use crate::decision_scheduler_context::DecisionSchedulerContext; +use crate::decision_text_tokenizer::DecisionTextTokenizer; + +const DECISION_ARCHITECTURES: [&str; 2] = ["qwen35", "qwen35moe"]; +const DECISION_SLOTS_MINIMUM: u16 = 2; + +pub struct DecisionPipeline { + pub inference_runtime_context: InferenceRuntimeContext, + pub model_path: PathBuf, + pub model_runtime_parameters: ModelRuntimeParameters, + pub pointer_head_path: PathBuf, +} + +impl DecisionPipeline { + pub async fn spawn( + self, + cancellation_token: &CancellationToken, + ) -> Result, DecisionError> { + let (scheduler_ready_tx, scheduler_ready_rx) = oneshot::channel(); + let agent_shutdown = cancellation_token.clone(); + + let scheduler_thread_handle = + thread::spawn(move || self.run_scheduler(&agent_shutdown, scheduler_ready_tx)); + + await_scheduler_startup( + cancellation_token, + scheduler_ready_rx, + scheduler_thread_handle, + ) + .await + } + + fn register_incompatibility( + &self, + incompatibility: PointerHeadIncompatibility, + ) -> DecisionError { + self.inference_runtime_context + .slot_aggregated_status + .register_issue(AgentIssue::PointerHeadIncompatibleWithModel( + PointerHeadIncompatibleWithModelParams { + incompatibility: incompatibility.clone(), + pointer_head_path: ModelPath::from(self.pointer_head_path.as_path()), + }, + )); + + DecisionError::PointerHeadIncompatibleWithModel { incompatibility } + } + + fn load_pointer_head(&self) -> Result { + PointerHead::load(&self.pointer_head_path).map_err(|source| { + self.inference_runtime_context + .slot_aggregated_status + .register_issue(AgentIssue::PointerHeadCannotBeLoaded(ModelPath::from( + self.pointer_head_path.as_path(), + ))); + + DecisionError::PointerHeadCannotBeLoaded(source) + }) + } + + fn require_decision_architecture( + &self, + loaded_llama_model: &LoadedLlamaModel, + ) -> Result<(), DecisionError> { + let architecture = loaded_llama_model + .model + .meta_val_str("general.architecture") + .map_err(DecisionError::ModelArchitectureUnreadable)?; + + if DECISION_ARCHITECTURES.contains(&architecture.as_str()) { + return Ok(()); + } + + self.inference_runtime_context + .slot_aggregated_status + .register_issue(AgentIssue::ModelArchitectureUnsupportedForDecisions( + ModelArchitectureUnsupportedForDecisionsParams { + architecture: architecture.clone(), + model_path: ModelPath::from(self.model_path.as_path()), + }, + )); + + Err(DecisionError::ModelArchitectureUnsupported { architecture }) + } + + fn require_enough_slots(&self) -> Result { + let desired_slots = self + .inference_runtime_context + .slot_aggregated_status + .desired_slots_total; + + if desired_slots >= DECISION_SLOTS_MINIMUM { + return Ok(desired_slots); + } + + self.inference_runtime_context + .slot_aggregated_status + .register_issue(AgentIssue::SlotsInsufficientForDecisions( + SlotsInsufficientForDecisionsParams { + desired_slots, + required_slots: DECISION_SLOTS_MINIMUM, + }, + )); + + Err(DecisionError::SlotsInsufficient { + desired_slots, + required_slots: DECISION_SLOTS_MINIMUM, + }) + } + + fn resolve_delimiter_tokens( + &self, + loaded_llama_model: &LoadedLlamaModel, + pointer_head: &PointerHead, + ) -> Result { + let model_hidden_size = loaded_llama_model.model.n_embd() as usize; + + if pointer_head.hidden_size() != model_hidden_size { + return Err(self.register_incompatibility( + PointerHeadIncompatibility::HiddenSizeMismatch { + model_hidden_size, + pointer_head_hidden_size: pointer_head.hidden_size(), + }, + )); + } + + match DecisionDelimiterTokens::resolve(&loaded_llama_model.model, &pointer_head.delimiters) + { + Err(DecisionError::PointerHeadIncompatibleWithModel { incompatibility }) => { + Err(self.register_incompatibility(incompatibility)) + } + resolution => resolution, + } + } + + fn run_scheduler( + self, + agent_shutdown: &CancellationToken, + scheduler_ready_tx: oneshot::Sender>>, + ) -> Result<(), DecisionError> { + let desired_slots_total = self.require_enough_slots()?; + let pointer_head = self.load_pointer_head()?; + let n_batch = self.model_runtime_parameters.n_batch.tokens_usize(); + let thread_count = inference_thread_count()?; + let loaded_llama_model = LoadedLlamaModel::load( + &self.inference_runtime_context, + &self.model_path, + &LlamaModelParams::default() + .with_n_gpu_layers(self.model_runtime_parameters.n_gpu_layers), + )?; + + self.inference_runtime_context + .slot_aggregated_status + .set_model_path(Some(ModelPath::from(self.model_path.as_path()).model_path)); + self.require_decision_architecture(&loaded_llama_model)?; + + let delimiter_tokens = self.resolve_delimiter_tokens(&loaded_llama_model, &pointer_head)?; + let mut llama_context = loaded_llama_model.create_llama_context( + &self.inference_runtime_context, + LlamaContextSettings { + model_runtime_parameters: &self.model_runtime_parameters, + n_seq_max: u32::from(desired_slots_total), + thread_count, + } + .into_llama_context_params() + .with_kv_unified(true), + )?; + + llama_context + .enable_masked_nextn_embeddings() + .map_err(DecisionError::HiddenStatesUnsupported)?; + + let mut batch = + LlamaBatch::new(n_batch, 1).map_err(AgentRuntimeError::BatchAllocationFailed)?; + + loaded_llama_model.warm_up_llama_context( + &mut llama_context, + &mut batch, + desired_slots_total, + ); + + let context_cells = llama_context.n_ctx(); + let (scheduler_message_tx, scheduler_message_rx) = channel(); + let request_preparer = Arc::new(SchedulerRequestPreparer { + agent_name: self.inference_runtime_context.agent_name.clone(), + preparation: DecisionRequestPreparer { + context_cells, + delimiter_tokens, + text_tokenizer: DecisionTextTokenizer { + loaded_llama_model: loaded_llama_model.clone(), + }, + }, + preparation_tasks: TaskTracker::new(), + scheduler_message_tx, + }); + + if matches!( + send_startup_signal(scheduler_ready_tx, request_preparer), + StartupSignalDelivery::Abandoned + ) { + return Ok(()); + } + + DecisionScheduler { + active_requests: VecDeque::new(), + agent_shutdown: agent_shutdown.clone(), + batch, + capacity_ledger: DecisionCapacityLedger::new( + context_cells as usize, + desired_slots_total, + ), + llama_context, + pending_requests: VecDeque::new(), + scheduler_context: DecisionSchedulerContext { + agent_name: self.inference_runtime_context.agent_name, + n_batch, + pointer_head, + }, + scheduler_message_rx, + shutdown_requested: false, + } + .run(); + + Ok(()) + } +} diff --git a/paddler_agent_decision/src/decision_question_row.rs b/paddler_agent_decision/src/decision_question_row.rs new file mode 100644 index 000000000..764f753bc --- /dev/null +++ b/paddler_agent_decision/src/decision_question_row.rs @@ -0,0 +1,38 @@ +use llama_cpp_bindings::token::LlamaToken; + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct DecisionQuestionRow { + pub id: String, + pub option_end_offsets: Vec, + pub tokens: Vec, +} + +impl DecisionQuestionRow { + #[must_use] + pub fn is_output(&self, row_offset: usize) -> bool { + row_offset + 1 == self.tokens.len() || self.option_end_offsets.contains(&row_offset) + } +} + +#[cfg(test)] +mod tests { + use llama_cpp_bindings::token::LlamaToken; + + use super::DecisionQuestionRow; + + #[test] + fn option_ends_and_the_closing_decide_token_are_outputs() { + let row = DecisionQuestionRow { + id: "question".to_owned(), + option_end_offsets: vec![2], + tokens: (0..4).map(LlamaToken::new).collect(), + }; + + assert_eq!( + (0..4) + .map(|row_offset| row.is_output(row_offset)) + .collect::>(), + vec![false, false, true, true] + ); + } +} diff --git a/paddler_agent_decision/src/decision_request_phase.rs b/paddler_agent_decision/src/decision_request_phase.rs new file mode 100644 index 000000000..334a725c3 --- /dev/null +++ b/paddler_agent_decision/src/decision_request_phase.rs @@ -0,0 +1,5 @@ +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum DecisionRequestPhase { + AnsweringQuestion { next_offset: usize }, + IngestingState { next_offset: usize }, +} diff --git a/paddler_agent_decision/src/decision_request_preparer.rs b/paddler_agent_decision/src/decision_request_preparer.rs new file mode 100644 index 000000000..a1dd545ec --- /dev/null +++ b/paddler_agent_decision/src/decision_request_preparer.rs @@ -0,0 +1,107 @@ +use std::time::Instant; + +use paddler_agent_runtime::agent_request::AgentRequest; +use paddler_agent_runtime::prepares_scheduler_command::PreparesSchedulerCommand; +use paddler_messaging::decision_question::DecisionQuestion; +use paddler_messaging::decision_result::DecisionResult; +use paddler_messaging::oversized_decision_details::OversizedDecisionDetails; +use paddler_messaging::request_params::decide_params::DecideParams; + +use crate::decision_delimiter_tokens::DecisionDelimiterTokens; +use crate::decision_error::DecisionError; +use crate::decision_text_tokenizer::DecisionTextTokenizer; +use crate::decision_token_layout::DecisionTokenLayout; +use crate::prepared_decision_request::PreparedDecisionRequest; +use crate::tokenized_decision_question::TokenizedDecisionQuestion; + +pub struct DecisionRequestPreparer { + pub context_cells: u32, + pub delimiter_tokens: DecisionDelimiterTokens, + pub text_tokenizer: DecisionTextTokenizer, +} + +impl DecisionRequestPreparer { + fn tokenize_question( + &self, + DecisionQuestion { + id, + instructions, + options, + }: DecisionQuestion, + ) -> Result { + Ok(TokenizedDecisionQuestion { + id, + instructions: self.text_tokenizer.tokenize(&instructions)?, + options: options + .iter() + .map(|option| self.text_tokenizer.tokenize(option)) + .collect::, _>>()?, + }) + } + + fn prepare( + &self, + AgentRequest { + params: + DecideParams { + last_question, + leading_questions, + state, + }, + response_tx: decision_result_tx, + slot_guard, + stop_rx: decision_stop_rx, + }: AgentRequest, + ) -> Result { + let started_at = Instant::now(); + let layout = DecisionTokenLayout::new( + &self.delimiter_tokens, + self.text_tokenizer.tokenize(&state)?, + leading_questions + .into_iter() + .map(|question| self.tokenize_question(question)) + .collect::, _>>()?, + self.tokenize_question(last_question)?, + ); + let required_tokens = layout.required_cells(); + + if required_tokens > self.context_cells as usize { + return Err(DecisionError::RequestExceedsContext { + details: OversizedDecisionDetails { + context_size: self.context_cells, + required_tokens, + }, + }); + } + + Ok(PreparedDecisionRequest { + decision_result_tx, + decision_stop_rx, + layout, + slot_guard, + started_at, + }) + } +} + +impl PreparesSchedulerCommand for DecisionRequestPreparer { + type Command = PreparedDecisionRequest; + type Request = AgentRequest; + + fn prepare_scheduler_command( + &self, + agent_name: Option<&str>, + request: Self::Request, + ) -> Option { + let decision_result_tx = request.response_tx.clone(); + + match self.prepare(request) { + Ok(prepared) => Some(prepared), + Err(rejection) => { + rejection.report(agent_name, &decision_result_tx); + + None + } + } + } +} diff --git a/paddler_agent_decision/src/decision_scheduler.rs b/paddler_agent_decision/src/decision_scheduler.rs new file mode 100644 index 000000000..aafcac58d --- /dev/null +++ b/paddler_agent_decision/src/decision_scheduler.rs @@ -0,0 +1,154 @@ +use std::collections::VecDeque; +use std::sync::mpsc::Receiver; +use std::sync::mpsc::RecvError; +use std::sync::mpsc::TryRecvError; + +use llama_cpp_bindings::context::LlamaContext; +use llama_cpp_bindings::llama_batch::LlamaBatch; +use log::info; +use tokio_util::sync::CancellationToken; + +use paddler_agent_runtime::receives_stop_request::ReceivesStopRequest as _; +use paddler_agent_runtime::scheduler_message::SchedulerMessage; +use paddler_agent_runtime::send_result_or_warn::send_result_or_warn; + +use crate::active_decision_request::ActiveDecisionRequest; +use crate::decision_advance::DecisionAdvance; +use crate::decision_capacity_ledger::DecisionCapacityLedger; +use crate::decision_decoder::DecisionDecoder; +use crate::decision_error::DecisionError; +use crate::decision_scheduler_context::DecisionSchedulerContext; +use crate::prepared_decision_request::PreparedDecisionRequest; + +pub struct DecisionScheduler<'model> { + pub active_requests: VecDeque, + pub agent_shutdown: CancellationToken, + pub batch: LlamaBatch<'static>, + pub capacity_ledger: DecisionCapacityLedger, + pub llama_context: LlamaContext<'model>, + pub pending_requests: VecDeque, + pub scheduler_context: DecisionSchedulerContext, + pub scheduler_message_rx: Receiver>, + pub shutdown_requested: bool, +} + +impl DecisionScheduler<'_> { + pub fn run(&mut self) { + info!( + "{:?}: decision scheduler started", + self.scheduler_context.agent_name + ); + + while !self.agent_shutdown.is_cancelled() { + if self.active_requests.is_empty() && self.pending_requests.is_empty() { + if self.shutdown_requested { + break; + } + + self.wait_for_next_scheduler_message(); + } else { + self.accept_new_scheduler_messages(); + } + + self.admit_pending_requests(); + self.advance_next_active_request(); + } + + self.reject_unfinished_requests(); + self.llama_context.synchronize(); + + info!( + "{:?}: decision scheduler stopped", + self.scheduler_context.agent_name + ); + } + + fn accept_new_scheduler_messages(&mut self) { + loop { + match self.scheduler_message_rx.try_recv() { + Ok(SchedulerMessage::Command(request)) => self.pending_requests.push_back(request), + Ok(SchedulerMessage::Shutdown) | Err(TryRecvError::Disconnected) => { + self.shutdown_requested = true; + + break; + } + Err(TryRecvError::Empty) => break, + } + } + } + + fn admit_pending_requests(&mut self) { + while let Some(mut request) = self.pending_requests.pop_front() { + if request.decision_stop_rx.is_stop_requested() { + continue; + } + + let Some(admission) = self.capacity_ledger.admit(&request.layout) else { + self.pending_requests.push_front(request); + + break; + }; + + self.active_requests + .push_back(ActiveDecisionRequest::new(request, admission)); + } + } + + fn advance_next_active_request(&mut self) { + let Some(mut request) = self.active_requests.pop_front() else { + return; + }; + let mut decoder = DecisionDecoder { + batch: &mut self.batch, + llama_context: &mut self.llama_context, + n_batch: self.scheduler_context.n_batch, + }; + let agent_name = self.scheduler_context.agent_name.as_deref(); + + if request.is_stop_requested() { + if let Err(release_error) = request.release_sequences(&mut decoder) { + release_error.report(agent_name, &request.decision_result_tx); + } + + return; + } + + match request.advance(&mut decoder, &self.scheduler_context.pointer_head) { + Ok(DecisionAdvance::Continuing) => self.active_requests.push_back(request), + Ok(DecisionAdvance::Completed) => match request.release_sequences(&mut decoder) { + Ok(()) => { + send_result_or_warn(agent_name, &request.decision_result_tx, request.summary()); + } + Err(release_error) => { + release_error.report(agent_name, &request.decision_result_tx); + } + }, + Err(advance_error) => { + if let Err(release_error) = request.release_sequences(&mut decoder) { + release_error.report(agent_name, &request.decision_result_tx); + } + + advance_error.report(agent_name, &request.decision_result_tx); + } + } + } + + fn reject_unfinished_requests(&mut self) { + let agent_name = self.scheduler_context.agent_name.as_deref(); + + for request in self.active_requests.drain(..) { + DecisionError::SchedulerUnavailable.report(agent_name, &request.decision_result_tx); + } + + for request in self.pending_requests.drain(..) { + DecisionError::SchedulerUnavailable.report(agent_name, &request.decision_result_tx); + } + } + + fn wait_for_next_scheduler_message(&mut self) { + match self.scheduler_message_rx.recv() { + Ok(SchedulerMessage::Command(request)) => self.pending_requests.push_back(request), + Ok(SchedulerMessage::Shutdown) | Err(RecvError) => self.shutdown_requested = true, + } + } +} diff --git a/paddler_agent_decision/src/decision_scheduler_context.rs b/paddler_agent_decision/src/decision_scheduler_context.rs new file mode 100644 index 000000000..d7bae17e1 --- /dev/null +++ b/paddler_agent_decision/src/decision_scheduler_context.rs @@ -0,0 +1,7 @@ +use paddler_agent_pointer_head::pointer_head::PointerHead; + +pub struct DecisionSchedulerContext { + pub agent_name: Option, + pub n_batch: usize, + pub pointer_head: PointerHead, +} diff --git a/paddler_agent_decision/src/decision_text_tokenizer.rs b/paddler_agent_decision/src/decision_text_tokenizer.rs new file mode 100644 index 000000000..b75782fd7 --- /dev/null +++ b/paddler_agent_decision/src/decision_text_tokenizer.rs @@ -0,0 +1,25 @@ +use icu_normalizer::ComposingNormalizerBorrowed; +use llama_cpp_bindings::model::AddBos; +use llama_cpp_bindings::model::ParseSpecialTokens; +use llama_cpp_bindings::token::LlamaToken; + +use paddler_agent_runtime::loaded_llama_model::LoadedLlamaModel; + +use crate::decision_error::DecisionError; + +pub struct DecisionTextTokenizer { + pub loaded_llama_model: LoadedLlamaModel, +} + +impl DecisionTextTokenizer { + pub fn tokenize(&self, text: &str) -> Result, DecisionError> { + self.loaded_llama_model + .model + .str_to_token( + &ComposingNormalizerBorrowed::new_nfc().normalize(text), + AddBos::Never, + ParseSpecialTokens::Never, + ) + .map_err(DecisionError::InputTokenizationFailed) + } +} diff --git a/paddler_agent_decision/src/decision_token_layout.rs b/paddler_agent_decision/src/decision_token_layout.rs new file mode 100644 index 000000000..11fab7285 --- /dev/null +++ b/paddler_agent_decision/src/decision_token_layout.rs @@ -0,0 +1,200 @@ +use std::collections::VecDeque; + +use llama_cpp_bindings::token::LlamaToken; + +use crate::decision_delimiter_tokens::DecisionDelimiterTokens; +use crate::decision_question_row::DecisionQuestionRow; +use crate::tokenized_decision_question::TokenizedDecisionQuestion; + +fn question_row( + delimiter_tokens: &DecisionDelimiterTokens, + TokenizedDecisionQuestion { + id, + instructions, + options, + }: TokenizedDecisionQuestion, +) -> DecisionQuestionRow { + let mut tokens = Vec::with_capacity( + instructions.len() + options.iter().map(|option| option.len() + 2).sum::() + 2, + ); + let mut option_end_offsets = Vec::with_capacity(options.len()); + + tokens.push(delimiter_tokens.question); + tokens.extend(instructions); + + for option in options { + tokens.push(delimiter_tokens.option_start); + tokens.extend(option); + option_end_offsets.push(tokens.len()); + tokens.push(delimiter_tokens.option_end); + } + + tokens.push(delimiter_tokens.decide); + + DecisionQuestionRow { + id, + option_end_offsets, + tokens, + } +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct DecisionTokenLayout { + pub first_question: DecisionQuestionRow, + pub remaining_questions: VecDeque, + pub state: Vec, +} + +impl DecisionTokenLayout { + #[must_use] + pub fn new( + delimiter_tokens: &DecisionDelimiterTokens, + state: Vec, + leading_questions: Vec, + last_question: TokenizedDecisionQuestion, + ) -> Self { + let mut state_tokens = Vec::with_capacity(state.len() + 1); + + state_tokens.push(delimiter_tokens.state); + state_tokens.extend(state); + + let mut remaining_questions: VecDeque = leading_questions + .into_iter() + .map(|question| question_row(delimiter_tokens, question)) + .collect(); + let last_question = question_row(delimiter_tokens, last_question); + let first_question = match remaining_questions.pop_front() { + Some(first_question) => { + remaining_questions.push_back(last_question); + + first_question + } + None => last_question, + }; + + Self { + first_question, + remaining_questions, + state: state_tokens, + } + } + + #[must_use] + pub fn needs_question_lane(&self) -> bool { + !self.remaining_questions.is_empty() + } + + #[must_use] + pub fn required_cells(&self) -> usize { + self.state.len() + + self + .remaining_questions + .iter() + .map(|question| question.tokens.len()) + .fold(self.first_question.tokens.len(), usize::max) + } + + #[must_use] + pub fn total_tokens(&self) -> usize { + self.state.len() + + self.first_question.tokens.len() + + self + .remaining_questions + .iter() + .map(|question| question.tokens.len()) + .sum::() + } +} + +#[cfg(test)] +mod tests { + use llama_cpp_bindings::token::LlamaToken; + + use super::DecisionTokenLayout; + use crate::decision_delimiter_tokens::DecisionDelimiterTokens; + use crate::decision_question_row::DecisionQuestionRow; + use crate::tokenized_decision_question::TokenizedDecisionQuestion; + + fn tokens(ids: &[i32]) -> Vec { + ids.iter().copied().map(LlamaToken::new).collect() + } + + fn delimiter_tokens() -> DecisionDelimiterTokens { + DecisionDelimiterTokens { + decide: LlamaToken::new(-5), + option_end: LlamaToken::new(-4), + option_start: LlamaToken::new(-3), + question: LlamaToken::new(-2), + state: LlamaToken::new(-1), + } + } + + fn first_question() -> TokenizedDecisionQuestion { + TokenizedDecisionQuestion { + id: "first".to_owned(), + instructions: tokens(&[20]), + options: vec![tokens(&[30, 31]), tokens(&[32])], + } + } + + fn last_question() -> TokenizedDecisionQuestion { + TokenizedDecisionQuestion { + id: "last".to_owned(), + instructions: Vec::new(), + options: vec![tokens(&[40])], + } + } + + fn two_question_layout() -> DecisionTokenLayout { + DecisionTokenLayout::new( + &delimiter_tokens(), + tokens(&[10, 11]), + vec![first_question()], + last_question(), + ) + } + + #[test] + fn lays_out_the_state_and_one_row_per_question_with_kev_delimiters() { + assert_eq!( + two_question_layout(), + DecisionTokenLayout { + first_question: DecisionQuestionRow { + id: "first".to_owned(), + option_end_offsets: vec![5, 8], + tokens: tokens(&[-2, 20, -3, 30, 31, -4, -3, 32, -4, -5]), + }, + remaining_questions: [DecisionQuestionRow { + id: "last".to_owned(), + option_end_offsets: vec![3], + tokens: tokens(&[-2, -3, 40, -4, -5]), + }] + .into(), + state: tokens(&[-1, 10, 11]), + } + ); + } + + #[test] + fn a_single_question_is_the_first_question_and_needs_no_question_lane() { + let layout = DecisionTokenLayout::new( + &delimiter_tokens(), + tokens(&[10]), + Vec::new(), + last_question(), + ); + + assert_eq!(layout.first_question.id, "last"); + assert!(!layout.needs_question_lane()); + } + + #[test] + fn a_decision_needs_its_state_and_its_longest_question_in_the_cache() { + assert_eq!(two_question_layout().required_cells(), 13); + } + + #[test] + fn a_decision_decodes_its_state_once_and_every_question_row() { + assert_eq!(two_question_layout().total_tokens(), 18); + } +} diff --git a/paddler_agent_decision/src/lib.rs b/paddler_agent_decision/src/lib.rs new file mode 100644 index 000000000..7e6caa33f --- /dev/null +++ b/paddler_agent_decision/src/lib.rs @@ -0,0 +1,19 @@ +pub mod active_decision_request; +pub mod decision_admission; +pub mod decision_advance; +pub mod decision_capacity_ledger; +pub mod decision_decoder; +pub mod decision_delimiter_tokens; +pub mod decision_error; +pub mod decision_pipeline; +pub mod decision_question_row; +pub mod decision_request_phase; +pub mod decision_request_preparer; +pub mod decision_scheduler; +pub mod decision_scheduler_context; +pub mod decision_text_tokenizer; +pub mod decision_token_layout; +pub mod prepared_decision_request; +pub mod promised_decision_cells; +pub mod question_lane; +pub mod tokenized_decision_question; diff --git a/paddler_agent_decision/src/prepared_decision_request.rs b/paddler_agent_decision/src/prepared_decision_request.rs new file mode 100644 index 000000000..0cb148418 --- /dev/null +++ b/paddler_agent_decision/src/prepared_decision_request.rs @@ -0,0 +1,73 @@ +use std::time::Instant; + +use tokio::sync::mpsc; + +use paddler_agent_runtime::scheduler_command::SchedulerCommand; +use paddler_agent_status::slot_guard::SlotGuard; +use paddler_messaging::decision_result::DecisionResult; + +use crate::decision_error::DecisionError; +use crate::decision_token_layout::DecisionTokenLayout; + +pub struct PreparedDecisionRequest { + pub decision_result_tx: mpsc::UnboundedSender, + pub decision_stop_rx: mpsc::UnboundedReceiver<()>, + pub layout: DecisionTokenLayout, + pub slot_guard: SlotGuard, + pub started_at: Instant, +} + +impl SchedulerCommand for PreparedDecisionRequest { + fn reject_because_the_scheduler_stopped(self, agent_name: Option<&str>) { + DecisionError::SchedulerUnavailable.report(agent_name, &self.decision_result_tx); + } +} + +#[cfg(test)] +mod tests { + use std::collections::VecDeque; + use std::sync::Arc; + use std::time::Instant; + + use llama_cpp_bindings::token::LlamaToken; + use tokio::sync::mpsc; + + use paddler_agent_runtime::scheduler_command::SchedulerCommand as _; + use paddler_agent_status::slot_aggregated_status::SlotAggregatedStatus; + use paddler_agent_status::slot_guard::SlotGuard; + use paddler_messaging::decision_result::DecisionResult; + + use super::PreparedDecisionRequest; + use crate::decision_question_row::DecisionQuestionRow; + use crate::decision_token_layout::DecisionTokenLayout; + + #[test] + fn rejects_a_decision_when_the_scheduler_stopped() { + let (decision_result_tx, mut decision_result_rx) = mpsc::unbounded_channel(); + let (_decision_stop_tx, decision_stop_rx) = mpsc::unbounded_channel(); + + PreparedDecisionRequest { + decision_result_tx, + decision_stop_rx, + layout: DecisionTokenLayout { + first_question: DecisionQuestionRow { + id: "paid".to_owned(), + option_end_offsets: Vec::new(), + tokens: vec![LlamaToken::new(0)], + }, + remaining_questions: VecDeque::new(), + state: Vec::new(), + }, + slot_guard: SlotGuard::new(Arc::new(SlotAggregatedStatus::new(1))), + started_at: Instant::now(), + } + .reject_because_the_scheduler_stopped(Some("agent")); + + assert_eq!( + decision_result_rx.try_recv(), + Ok(DecisionResult::SchedulerUnavailable( + "agent: the scheduler is no longer accepting requests".to_owned() + )) + ); + } +} diff --git a/paddler_agent_decision/src/promised_decision_cells.rs b/paddler_agent_decision/src/promised_decision_cells.rs new file mode 100644 index 000000000..180db5741 --- /dev/null +++ b/paddler_agent_decision/src/promised_decision_cells.rs @@ -0,0 +1,14 @@ +use std::cell::Cell; +use std::rc::Rc; + +pub struct PromisedDecisionCells { + pub available_cells: Rc>, + pub cells: usize, +} + +impl Drop for PromisedDecisionCells { + fn drop(&mut self) { + self.available_cells + .set(self.available_cells.get() + self.cells); + } +} diff --git a/paddler_agent_decision/src/question_lane.rs b/paddler_agent_decision/src/question_lane.rs new file mode 100644 index 000000000..bfe995807 --- /dev/null +++ b/paddler_agent_decision/src/question_lane.rs @@ -0,0 +1,6 @@ +use paddler_agent_runtime::sequence_id_guard::SequenceIdGuard; + +pub enum QuestionLane { + Released, + Reserved(SequenceIdGuard), +} diff --git a/paddler_agent_decision/src/tokenized_decision_question.rs b/paddler_agent_decision/src/tokenized_decision_question.rs new file mode 100644 index 000000000..05fcbbe24 --- /dev/null +++ b/paddler_agent_decision/src/tokenized_decision_question.rs @@ -0,0 +1,7 @@ +use llama_cpp_bindings::token::LlamaToken; + +pub struct TokenizedDecisionQuestion { + pub id: String, + pub instructions: Vec, + pub options: Vec>, +} diff --git a/paddler_agent_embeddings/Cargo.toml b/paddler_agent_embeddings/Cargo.toml new file mode 100644 index 000000000..3e6b1965f --- /dev/null +++ b/paddler_agent_embeddings/Cargo.toml @@ -0,0 +1,29 @@ +[package] +name = "paddler_agent_embeddings" +authors.workspace = true +description.workspace = true +edition.workspace = true +homepage.workspace = true +license.workspace = true +repository.workspace = true +version.workspace = true + +[dependencies] +llama-cpp-bindings = { workspace = true } +log = { workspace = true } +thiserror = { workspace = true } +tokio = { workspace = true } +tokio-util = { workspace = true } +paddler_agent_runtime = { workspace = true } +paddler_agent_status = { workspace = true } +paddler_inference_parameters = { workspace = true } +paddler_messaging = { workspace = true } + +[features] +default = [] +cuda = ["llama-cpp-bindings/cuda", "paddler_agent_runtime/cuda"] +metal = ["llama-cpp-bindings/metal", "paddler_agent_runtime/metal"] +vulkan = ["llama-cpp-bindings/vulkan", "paddler_agent_runtime/vulkan"] + +[lints] +workspace = true diff --git a/paddler_agent/src/agent_pooling_type.rs b/paddler_agent_embeddings/src/agent_pooling_type.rs similarity index 100% rename from paddler_agent/src/agent_pooling_type.rs rename to paddler_agent_embeddings/src/agent_pooling_type.rs diff --git a/paddler_agent/src/converts_to_llama_pooling_type.rs b/paddler_agent_embeddings/src/converts_to_llama_pooling_type.rs similarity index 100% rename from paddler_agent/src/converts_to_llama_pooling_type.rs rename to paddler_agent_embeddings/src/converts_to_llama_pooling_type.rs diff --git a/paddler_agent/src/embedding_batch_preparer.rs b/paddler_agent_embeddings/src/embedding_batch_preparer.rs similarity index 60% rename from paddler_agent/src/embedding_batch_preparer.rs rename to paddler_agent_embeddings/src/embedding_batch_preparer.rs index 4ef98112a..58b0a29d0 100644 --- a/paddler_agent/src/embedding_batch_preparer.rs +++ b/paddler_agent_embeddings/src/embedding_batch_preparer.rs @@ -1,26 +1,24 @@ -use std::sync::Arc; - use llama_cpp_bindings::model::AddBos; -use llama_cpp_bindings::model::LlamaModel; +use llama_cpp_bindings::model::ParseSpecialTokens; +use paddler_agent_runtime::agent_request::AgentRequest; +use paddler_agent_runtime::loaded_llama_model::LoadedLlamaModel; +use paddler_agent_runtime::prepares_scheduler_command::PreparesSchedulerCommand; use paddler_messaging::embedding_result::EmbeddingResult; use paddler_messaging::oversized_embedding_document_details::OversizedEmbeddingDocumentDetails; use paddler_messaging::request_params::generate_embedding_batch_params::GenerateEmbeddingBatchParams; -use crate::agent_request::AgentRequest; -use crate::embedding_batch_rejection::EmbeddingBatchRejection; +use crate::embedding_error::EmbeddingError; use crate::embedding_input_tokenized::EmbeddingInputTokenized; use crate::prepared_embedding_batch_request::PreparedEmbeddingBatchRequest; -use crate::require_embeddings_enabled::require_embeddings_enabled; pub struct EmbeddingBatchPreparer { - pub enable_embeddings: bool, - pub model: Arc, + pub loaded_llama_model: LoadedLlamaModel, pub n_batch: usize, } impl EmbeddingBatchPreparer { - pub fn prepare( + fn prepare( &self, AgentRequest { params: @@ -32,17 +30,16 @@ impl EmbeddingBatchPreparer { slot_guard, stop_rx: generate_embedding_stop_rx, }: AgentRequest, - ) -> Result { - require_embeddings_enabled(self.enable_embeddings)?; - + ) -> Result { let mut inputs = Vec::with_capacity(input_batch.len()); let mut oversized_documents = Vec::new(); for input in input_batch { let tokens = self + .loaded_llama_model .model - .str_to_token(&input.content, AddBos::Always) - .map_err(|source| EmbeddingBatchRejection::InputTokenizationFailed { + .str_to_token(&input.content, AddBos::Always, ParseSpecialTokens::Always) + .map_err(|source| EmbeddingError::InputTokenizationFailed { source_document_id: input.id.clone(), source, })?; @@ -71,3 +68,25 @@ impl EmbeddingBatchPreparer { }) } } + +impl PreparesSchedulerCommand for EmbeddingBatchPreparer { + type Command = PreparedEmbeddingBatchRequest; + type Request = AgentRequest; + + fn prepare_scheduler_command( + &self, + agent_name: Option<&str>, + request: Self::Request, + ) -> Option { + let generated_embedding_tx = request.response_tx.clone(); + + match self.prepare(request) { + Ok(prepared) => Some(prepared), + Err(rejection) => { + rejection.report(agent_name, &generated_embedding_tx); + + None + } + } + } +} diff --git a/paddler_agent/src/continuous_batch_embedding_processor.rs b/paddler_agent_embeddings/src/embedding_batch_processor.rs similarity index 54% rename from paddler_agent/src/continuous_batch_embedding_processor.rs rename to paddler_agent_embeddings/src/embedding_batch_processor.rs index ff34ca78e..24eb683d6 100644 --- a/paddler_agent/src/continuous_batch_embedding_processor.rs +++ b/paddler_agent_embeddings/src/embedding_batch_processor.rs @@ -3,37 +3,25 @@ use llama_cpp_bindings::llama_batch::LlamaBatch; use log::warn; use tokio::sync::mpsc; +use paddler_agent_runtime::receives_stop_request::ReceivesStopRequest as _; use paddler_messaging::embedding::Embedding; use paddler_messaging::embedding_normalization_method::EmbeddingNormalizationMethod; use paddler_messaging::embedding_result::EmbeddingResult; -use crate::continuous_batch_scheduler_context::ContinuousBatchSchedulerContext; -use crate::embedding_batch_rejection::EmbeddingBatchRejection; -use crate::embedding_input_tokenized::EmbeddingInputTokenized; +use crate::embedding_error::EmbeddingError; +use crate::embedding_scheduler_context::EmbeddingSchedulerContext; use crate::normalization::normalize_embedding::normalize_embedding; use crate::plan_embedding_batches::plan_embedding_batches; use crate::prepared_embedding_batch_request::PreparedEmbeddingBatchRequest; -use crate::receives_stop_request::ReceivesStopRequest as _; +use crate::sequenced_embedding_input::SequencedEmbeddingInput; -pub struct ContinuousBatchEmbeddingProcessor<'context> { - batch: &'context mut LlamaBatch<'static>, - llama_context: &'context mut LlamaContext<'static>, - scheduler_context: &'context ContinuousBatchSchedulerContext, +pub struct EmbeddingBatchProcessor<'scheduler, 'model> { + pub batch: &'scheduler mut LlamaBatch<'static>, + pub llama_context: &'scheduler mut LlamaContext<'model>, + pub scheduler_context: &'scheduler EmbeddingSchedulerContext, } -impl<'context> ContinuousBatchEmbeddingProcessor<'context> { - pub const fn new( - batch: &'context mut LlamaBatch<'static>, - llama_context: &'context mut LlamaContext<'static>, - scheduler_context: &'context ContinuousBatchSchedulerContext, - ) -> Self { - Self { - batch, - llama_context, - scheduler_context, - } - } - +impl EmbeddingBatchProcessor<'_, '_> { pub fn process_embedding_batch( &mut self, PreparedEmbeddingBatchRequest { @@ -44,7 +32,7 @@ impl<'context> ContinuousBatchEmbeddingProcessor<'context> { oversized_documents, slot_guard, }: PreparedEmbeddingBatchRequest, - ) -> Result<(), EmbeddingBatchRejection> { + ) -> Result<(), EmbeddingError> { let _slot_guard = slot_guard; for oversized_document in oversized_documents { @@ -60,14 +48,10 @@ impl<'context> ContinuousBatchEmbeddingProcessor<'context> { .send(EmbeddingResult::DocumentExceedsBatchSize( oversized_document, )) - .map_err(EmbeddingBatchRejection::ClientDisconnected)?; + .map_err(EmbeddingError::ClientDisconnected)?; } - let n_batch = self - .scheduler_context - .inference_parameters - .n_batch - .tokens_usize(); + let n_batch = self.scheduler_context.n_batch; let max_sequences_per_batch = self.scheduler_context.desired_slots_total; let token_counts: Vec = inputs.iter().map(|input| input.tokens.len()).collect(); @@ -82,18 +66,18 @@ impl<'context> ContinuousBatchEmbeddingProcessor<'context> { break; } - let batch_inputs: Vec<&EmbeddingInputTokenized> = - inputs[planned_batch].iter().collect(); + let batch_inputs: Vec = (0..max_sequences_per_batch) + .zip(&inputs[planned_batch]) + .map(|(sequence_id, input)| SequencedEmbeddingInput { + input, + sequence_id: i32::from(sequence_id), + }) + .collect(); - for (sequence_index, input) in batch_inputs.iter().enumerate() { + for SequencedEmbeddingInput { input, sequence_id } in &batch_inputs { self.batch - .add_sequence( - &input.tokens, - i32::try_from(sequence_index) - .map_err(EmbeddingBatchRejection::SequenceIndexOutOfRange)?, - true, - ) - .map_err(EmbeddingBatchRejection::BatchAssemblyFailed)?; + .add_sequence(&input.tokens, *sequence_id, true) + .map_err(EmbeddingError::BatchAssemblyFailed)?; } self.embedding_batch_decode( @@ -111,41 +95,34 @@ impl<'context> ContinuousBatchEmbeddingProcessor<'context> { } else { EmbeddingResult::Done }) - .map_err(EmbeddingBatchRejection::ClientDisconnected) + .map_err(EmbeddingError::ClientDisconnected) } fn embedding_batch_decode( &mut self, - current_batch_embeddings: &[&EmbeddingInputTokenized], + current_batch_embeddings: &[SequencedEmbeddingInput], generated_embedding_tx: &mpsc::UnboundedSender, normalization_method: &EmbeddingNormalizationMethod, - ) -> Result<(), EmbeddingBatchRejection> { + ) -> Result<(), EmbeddingError> { self.llama_context.clear_kv_cache(); self.llama_context .decode(self.batch) - .map_err(EmbeddingBatchRejection::DecodeFailed)?; + .map_err(EmbeddingError::DecodeFailed)?; - for (index, embedding_input_tokenized) in current_batch_embeddings.iter().enumerate() { + for SequencedEmbeddingInput { input, sequence_id } in current_batch_embeddings { let embedding = self .llama_context - .embeddings_seq_ith( - i32::try_from(index) - .map_err(EmbeddingBatchRejection::SequenceIndexOutOfRange)?, - ) - .map_err(EmbeddingBatchRejection::EmbeddingsUnavailable)?; + .embeddings_seq_ith(*sequence_id) + .map_err(EmbeddingError::EmbeddingsUnavailable)?; generated_embedding_tx .send(EmbeddingResult::Embedding(Embedding { embedding: normalize_embedding(embedding.to_vec(), normalization_method)?, normalization_method: normalization_method.clone(), - pooling_type: self - .scheduler_context - .inference_parameters - .pooling_type - .clone(), - source_document_id: embedding_input_tokenized.id.clone(), + pooling_type: self.scheduler_context.pooling_type.clone(), + source_document_id: input.id.clone(), })) - .map_err(EmbeddingBatchRejection::ClientDisconnected)?; + .map_err(EmbeddingError::ClientDisconnected)?; } self.batch.clear(); diff --git a/paddler_agent_embeddings/src/embedding_error.rs b/paddler_agent_embeddings/src/embedding_error.rs new file mode 100644 index 000000000..ca60e8699 --- /dev/null +++ b/paddler_agent_embeddings/src/embedding_error.rs @@ -0,0 +1,213 @@ +use std::num::TryFromIntError; + +use llama_cpp_bindings::batch_add_error::BatchAddError; +use llama_cpp_bindings::error::DecodeError; +use llama_cpp_bindings::error::EmbeddingsError; +use llama_cpp_bindings::error::StringToTokenError; +use log::error; +use tokio::sync::mpsc; +use tokio::sync::mpsc::error::SendError; + +use paddler_agent_runtime::agent_runtime_error::AgentRuntimeError; +use paddler_agent_runtime::rejection_description::rejection_description; +use paddler_agent_runtime::send_result_or_warn::send_result_or_warn; +use paddler_messaging::embedding_result::EmbeddingResult; +use paddler_messaging::inference_mode::InferenceMode; + +#[derive(Debug, thiserror::Error)] +pub enum EmbeddingError { + #[error(transparent)] + AgentRuntime(#[from] AgentRuntimeError), + + #[error("the agent serves {serving_inference_mode:?}, so it cannot generate embeddings")] + InferenceModeMismatch { + serving_inference_mode: InferenceMode, + }, + + #[error("failed to tokenize embedding input {source_document_id:?}: {source}")] + InputTokenizationFailed { + source_document_id: String, + #[source] + source: StringToTokenError, + }, + + #[error("no model is loaded")] + ModelNotLoaded, + + #[error("the scheduler is no longer accepting requests")] + SchedulerUnavailable, + + #[error("failed to add an embedding input to the batch: {0}")] + BatchAssemblyFailed(#[source] BatchAddError), + + #[error("the client stopped listening for embeddings: {0}")] + ClientDisconnected(#[source] SendError), + + #[error("llama.cpp rejected the embedding batch: {0}")] + DecodeFailed(#[source] DecodeError), + + #[error("{dimensions}-dimensional embedding is too long for RMS normalization: {source}")] + EmbeddingTooLongForRmsNormalization { + dimensions: usize, + #[source] + source: TryFromIntError, + }, + + #[error("failed to read the embedding of a batch sequence: {0}")] + EmbeddingsUnavailable(#[source] EmbeddingsError), +} + +impl EmbeddingError { + pub fn report( + self, + agent_name: Option<&str>, + generated_embedding_tx: &mpsc::UnboundedSender, + ) { + let message = rejection_description(agent_name, &self); + + error!("{message}"); + + let result = match self { + Self::AgentRuntime(_) => EmbeddingResult::AgentRuntimeFailed(message), + Self::BatchAssemblyFailed(_) => EmbeddingResult::BatchAssemblyFailed(message), + Self::ClientDisconnected(_) => return, + Self::DecodeFailed(_) => EmbeddingResult::DecodeFailed(message), + Self::EmbeddingTooLongForRmsNormalization { .. } => { + EmbeddingResult::EmbeddingTooLongForRmsNormalization(message) + } + Self::EmbeddingsUnavailable(_) => EmbeddingResult::EmbeddingsUnavailable(message), + Self::InferenceModeMismatch { .. } => EmbeddingResult::InferenceModeMismatch(message), + Self::InputTokenizationFailed { .. } => { + EmbeddingResult::InputTokenizationFailed(message) + } + Self::ModelNotLoaded => EmbeddingResult::ModelNotLoaded(message), + Self::SchedulerUnavailable => EmbeddingResult::SchedulerUnavailable(message), + }; + + send_result_or_warn(agent_name, generated_embedding_tx, result); + } +} + +#[cfg(test)] +mod tests { + use std::ffi::CString; + use std::io; + use std::mem::discriminant; + + use llama_cpp_bindings::batch_add_error::BatchAddError; + use llama_cpp_bindings::error::DecodeError; + use llama_cpp_bindings::error::EmbeddingsError; + use llama_cpp_bindings::error::StringToTokenError; + use tokio::sync::mpsc; + use tokio::sync::mpsc::error::SendError; + use tokio::sync::mpsc::error::TryRecvError; + + use paddler_agent_runtime::agent_runtime_error::AgentRuntimeError; + use paddler_messaging::embedding_result::EmbeddingResult; + use paddler_messaging::inference_mode::InferenceMode; + + use super::EmbeddingError; + + #[test] + fn reports_a_request_for_another_mode_naming_the_served_mode() { + let (generated_embedding_tx, mut generated_embedding_rx) = mpsc::unbounded_channel(); + + EmbeddingError::InferenceModeMismatch { + serving_inference_mode: InferenceMode::TextGeneration, + } + .report(Some("agent"), &generated_embedding_tx); + + assert_eq!( + generated_embedding_rx.try_recv(), + Ok(EmbeddingResult::InferenceModeMismatch( + "agent: the agent serves TextGeneration, so it cannot generate embeddings" + .to_owned() + )) + ); + } + + #[test] + fn every_embedding_failure_reports_its_own_embedding_result() { + let description = String::new; + let failures = [ + ( + EmbeddingError::AgentRuntime(AgentRuntimeError::AvailableParallelismUnknown( + io::Error::other("parallelism unknown"), + )), + EmbeddingResult::AgentRuntimeFailed(description()), + ), + ( + EmbeddingError::BatchAssemblyFailed(BatchAddError::EmptyBuffer), + EmbeddingResult::BatchAssemblyFailed(description()), + ), + ( + EmbeddingError::DecodeFailed(DecodeError::NoKvCacheSlot), + EmbeddingResult::DecodeFailed(description()), + ), + ( + EmbeddingError::EmbeddingTooLongForRmsNormalization { + dimensions: usize::MAX, + source: u16::try_from(usize::MAX).unwrap_err(), + }, + EmbeddingResult::EmbeddingTooLongForRmsNormalization(description()), + ), + ( + EmbeddingError::EmbeddingsUnavailable(EmbeddingsError::NextnEmbeddingsNotEnabled), + EmbeddingResult::EmbeddingsUnavailable(description()), + ), + ( + EmbeddingError::InferenceModeMismatch { + serving_inference_mode: InferenceMode::Decision, + }, + EmbeddingResult::InferenceModeMismatch(description()), + ), + ( + EmbeddingError::InputTokenizationFailed { + source_document_id: "document".to_owned(), + source: StringToTokenError::NulError(CString::new("a \0 byte").unwrap_err()), + }, + EmbeddingResult::InputTokenizationFailed(description()), + ), + ( + EmbeddingError::ModelNotLoaded, + EmbeddingResult::ModelNotLoaded(description()), + ), + ( + EmbeddingError::SchedulerUnavailable, + EmbeddingResult::SchedulerUnavailable(description()), + ), + ]; + + for (embedding_error, expected_result) in failures { + let (generated_embedding_tx, mut generated_embedding_rx) = mpsc::unbounded_channel(); + + embedding_error.report(None, &generated_embedding_tx); + + assert_eq!( + generated_embedding_rx.try_recv().as_ref().map(discriminant), + Ok(discriminant(&expected_result)) + ); + } + } + + #[test] + fn sends_nothing_to_a_client_that_already_stopped_listening() { + let (generated_embedding_tx, mut generated_embedding_rx) = mpsc::unbounded_channel(); + + EmbeddingError::ClientDisconnected(SendError(EmbeddingResult::Done)) + .report(Some("agent"), &generated_embedding_tx); + + assert_eq!(generated_embedding_rx.try_recv(), Err(TryRecvError::Empty)); + } + + #[test] + fn tolerates_a_disconnected_client() { + let (generated_embedding_tx, generated_embedding_rx) = mpsc::unbounded_channel(); + + drop(generated_embedding_rx); + + EmbeddingError::SchedulerUnavailable.report(None, &generated_embedding_tx); + + assert!(generated_embedding_tx.is_closed()); + } +} diff --git a/paddler_agent/src/embedding_input_tokenized.rs b/paddler_agent_embeddings/src/embedding_input_tokenized.rs similarity index 100% rename from paddler_agent/src/embedding_input_tokenized.rs rename to paddler_agent_embeddings/src/embedding_input_tokenized.rs diff --git a/paddler_agent_embeddings/src/embedding_pipeline.rs b/paddler_agent_embeddings/src/embedding_pipeline.rs new file mode 100644 index 000000000..46985cf2d --- /dev/null +++ b/paddler_agent_embeddings/src/embedding_pipeline.rs @@ -0,0 +1,142 @@ +use std::path::PathBuf; +use std::sync::Arc; +use std::sync::mpsc::channel; +use std::thread; + +use llama_cpp_bindings::llama_batch::LlamaBatch; +use llama_cpp_bindings::model::params::LlamaModelParams; +use tokio::sync::oneshot; +use tokio_util::sync::CancellationToken; +use tokio_util::task::TaskTracker; + +use paddler_agent_runtime::agent_runtime_error::AgentRuntimeError; +use paddler_agent_runtime::await_scheduler_startup::await_scheduler_startup; +use paddler_agent_runtime::inference_runtime_context::InferenceRuntimeContext; +use paddler_agent_runtime::inference_thread_count::inference_thread_count; +use paddler_agent_runtime::llama_context_settings::LlamaContextSettings; +use paddler_agent_runtime::loaded_llama_model::LoadedLlamaModel; +use paddler_agent_runtime::scheduler_request_preparer::SchedulerRequestPreparer; +use paddler_agent_runtime::scheduler_spawn_outcome::SchedulerSpawnOutcome; +use paddler_agent_runtime::send_startup_signal::send_startup_signal; +use paddler_agent_runtime::startup_signal_delivery::StartupSignalDelivery; +use paddler_inference_parameters::embedding_parameters::EmbeddingParameters; +use paddler_inference_parameters::model_runtime_parameters::ModelRuntimeParameters; +use paddler_messaging::agent_issue_params::model_path::ModelPath; + +use crate::agent_pooling_type::AgentPoolingType; +use crate::converts_to_llama_pooling_type::ConvertsToLlamaPoolingType as _; +use crate::embedding_batch_preparer::EmbeddingBatchPreparer; +use crate::embedding_error::EmbeddingError; +use crate::embedding_scheduler::EmbeddingScheduler; +use crate::embedding_scheduler_context::EmbeddingSchedulerContext; + +pub struct EmbeddingPipeline { + pub embedding_parameters: EmbeddingParameters, + pub inference_runtime_context: InferenceRuntimeContext, + pub model_path: PathBuf, + pub model_runtime_parameters: ModelRuntimeParameters, +} + +impl EmbeddingPipeline { + pub async fn spawn( + self, + cancellation_token: &CancellationToken, + ) -> Result, EmbeddingError> { + let (scheduler_ready_tx, scheduler_ready_rx) = oneshot::channel(); + let agent_shutdown = cancellation_token.clone(); + + let scheduler_thread_handle = + thread::spawn(move || self.run_scheduler(&agent_shutdown, scheduler_ready_tx)); + + await_scheduler_startup( + cancellation_token, + scheduler_ready_rx, + scheduler_thread_handle, + ) + .await + } + + fn run_scheduler( + self, + agent_shutdown: &CancellationToken, + scheduler_ready_tx: oneshot::Sender>>, + ) -> Result<(), EmbeddingError> { + let Self { + embedding_parameters, + inference_runtime_context, + model_path, + model_runtime_parameters, + } = self; + let desired_slots_total = inference_runtime_context + .slot_aggregated_status + .desired_slots_total; + let n_batch = model_runtime_parameters.n_batch.tokens_usize(); + let thread_count = inference_thread_count()?; + let loaded_llama_model = LoadedLlamaModel::load( + &inference_runtime_context, + &model_path, + &LlamaModelParams::default().with_n_gpu_layers(model_runtime_parameters.n_gpu_layers), + )?; + + inference_runtime_context + .slot_aggregated_status + .set_model_path(Some(ModelPath::from(model_path.as_path()).model_path)); + + let mut llama_context = loaded_llama_model.create_llama_context( + &inference_runtime_context, + LlamaContextSettings { + model_runtime_parameters: &model_runtime_parameters, + n_seq_max: u32::from(desired_slots_total), + thread_count, + } + .into_llama_context_params() + .with_embeddings(true) + .with_n_ubatch(model_runtime_parameters.n_batch.tokens().get()) + .with_pooling_type( + AgentPoolingType(embedding_parameters.pooling_type.clone()).to_llama_pooling_type(), + ), + )?; + let mut batch = LlamaBatch::new(n_batch, i32::from(desired_slots_total)) + .map_err(AgentRuntimeError::BatchAllocationFailed)?; + + loaded_llama_model.warm_up_llama_context( + &mut llama_context, + &mut batch, + desired_slots_total, + ); + + let (scheduler_message_tx, scheduler_message_rx) = channel(); + let request_preparer = Arc::new(SchedulerRequestPreparer { + agent_name: inference_runtime_context.agent_name.clone(), + preparation: EmbeddingBatchPreparer { + loaded_llama_model: loaded_llama_model.clone(), + n_batch, + }, + preparation_tasks: TaskTracker::new(), + scheduler_message_tx, + }); + + if matches!( + send_startup_signal(scheduler_ready_tx, request_preparer), + StartupSignalDelivery::Abandoned + ) { + return Ok(()); + } + + EmbeddingScheduler { + agent_shutdown: agent_shutdown.clone(), + batch, + scheduler_message_rx, + llama_context, + scheduler_context: EmbeddingSchedulerContext { + agent_name: inference_runtime_context.agent_name, + desired_slots_total, + n_batch, + pooling_type: embedding_parameters.pooling_type, + }, + } + .run(); + + Ok(()) + } +} diff --git a/paddler_agent_embeddings/src/embedding_scheduler.rs b/paddler_agent_embeddings/src/embedding_scheduler.rs new file mode 100644 index 000000000..d62ce7dd9 --- /dev/null +++ b/paddler_agent_embeddings/src/embedding_scheduler.rs @@ -0,0 +1,60 @@ +use std::sync::mpsc::Receiver; +use std::sync::mpsc::RecvError; + +use llama_cpp_bindings::context::LlamaContext; +use llama_cpp_bindings::llama_batch::LlamaBatch; +use log::info; +use tokio_util::sync::CancellationToken; + +use paddler_agent_runtime::scheduler_message::SchedulerMessage; + +use crate::embedding_batch_processor::EmbeddingBatchProcessor; +use crate::embedding_scheduler_context::EmbeddingSchedulerContext; +use crate::prepared_embedding_batch_request::PreparedEmbeddingBatchRequest; + +pub struct EmbeddingScheduler<'model> { + pub agent_shutdown: CancellationToken, + pub batch: LlamaBatch<'static>, + pub scheduler_message_rx: Receiver>, + pub llama_context: LlamaContext<'model>, + pub scheduler_context: EmbeddingSchedulerContext, +} + +impl EmbeddingScheduler<'_> { + pub fn run(&mut self) { + info!( + "{:?}: embedding scheduler started", + self.scheduler_context.agent_name + ); + + while !self.agent_shutdown.is_cancelled() { + match self.scheduler_message_rx.recv() { + Ok(SchedulerMessage::Command(request)) => { + let generated_embedding_tx = request.generated_embedding_tx.clone(); + + if let Err(rejection) = (EmbeddingBatchProcessor { + batch: &mut self.batch, + llama_context: &mut self.llama_context, + scheduler_context: &self.scheduler_context, + }) + .process_embedding_batch(request) + { + rejection.report( + self.scheduler_context.agent_name.as_deref(), + &generated_embedding_tx, + ); + } + } + Ok(SchedulerMessage::Shutdown) | Err(RecvError) => break, + } + } + + self.llama_context.synchronize(); + self.llama_context.detach_threadpool(); + + info!( + "{:?}: embedding scheduler stopped", + self.scheduler_context.agent_name + ); + } +} diff --git a/paddler_agent_embeddings/src/embedding_scheduler_context.rs b/paddler_agent_embeddings/src/embedding_scheduler_context.rs new file mode 100644 index 000000000..f4dcf9069 --- /dev/null +++ b/paddler_agent_embeddings/src/embedding_scheduler_context.rs @@ -0,0 +1,8 @@ +use paddler_inference_parameters::pooling_type::PoolingType; + +pub struct EmbeddingSchedulerContext { + pub agent_name: Option, + pub desired_slots_total: u16, + pub n_batch: usize, + pub pooling_type: PoolingType, +} diff --git a/paddler_agent_embeddings/src/lib.rs b/paddler_agent_embeddings/src/lib.rs new file mode 100644 index 000000000..28324d42d --- /dev/null +++ b/paddler_agent_embeddings/src/lib.rs @@ -0,0 +1,13 @@ +pub mod agent_pooling_type; +pub mod converts_to_llama_pooling_type; +pub mod embedding_batch_preparer; +pub mod embedding_batch_processor; +pub mod embedding_error; +pub mod embedding_input_tokenized; +pub mod embedding_pipeline; +pub mod embedding_scheduler; +pub mod embedding_scheduler_context; +pub mod normalization; +pub mod plan_embedding_batches; +pub mod prepared_embedding_batch_request; +pub mod sequenced_embedding_input; diff --git a/paddler_agent/src/normalization/l2.rs b/paddler_agent_embeddings/src/normalization/l2.rs similarity index 100% rename from paddler_agent/src/normalization/l2.rs rename to paddler_agent_embeddings/src/normalization/l2.rs diff --git a/paddler_agent/src/normalization/mod.rs b/paddler_agent_embeddings/src/normalization/mod.rs similarity index 100% rename from paddler_agent/src/normalization/mod.rs rename to paddler_agent_embeddings/src/normalization/mod.rs diff --git a/paddler_agent/src/normalization/normalize_embedding.rs b/paddler_agent_embeddings/src/normalization/normalize_embedding.rs similarity index 86% rename from paddler_agent/src/normalization/normalize_embedding.rs rename to paddler_agent_embeddings/src/normalization/normalize_embedding.rs index 67a8c774f..3d031ce7c 100644 --- a/paddler_agent/src/normalization/normalize_embedding.rs +++ b/paddler_agent_embeddings/src/normalization/normalize_embedding.rs @@ -1,19 +1,19 @@ use paddler_messaging::embedding_normalization_method::EmbeddingNormalizationMethod; -use crate::embedding_batch_rejection::EmbeddingBatchRejection; +use crate::embedding_error::EmbeddingError; use crate::normalization::l2::l2; use crate::normalization::rms_norm::rms_norm; pub fn normalize_embedding( mut embedding: Vec, normalization_method: &EmbeddingNormalizationMethod, -) -> Result, EmbeddingBatchRejection> { +) -> Result, EmbeddingError> { match normalization_method { EmbeddingNormalizationMethod::None => {} EmbeddingNormalizationMethod::L2 => l2(&mut embedding), EmbeddingNormalizationMethod::RmsNorm { epsilon } => { rms_norm(&mut embedding, *epsilon).map_err(|source| { - EmbeddingBatchRejection::EmbeddingTooLongForRmsNormalization { + EmbeddingError::EmbeddingTooLongForRmsNormalization { dimensions: embedding.len(), source, } @@ -29,7 +29,7 @@ mod tests { use paddler_messaging::embedding_normalization_method::EmbeddingNormalizationMethod; use super::normalize_embedding; - use crate::embedding_batch_rejection::EmbeddingBatchRejection; + use crate::embedding_error::EmbeddingError; #[test] fn scales_the_embedding_to_unit_length_with_l2() { @@ -68,7 +68,7 @@ mod tests { assert!(matches!( result, - Err(EmbeddingBatchRejection::EmbeddingTooLongForRmsNormalization { + Err(EmbeddingError::EmbeddingTooLongForRmsNormalization { dimensions, .. }) if dimensions == oversized_length diff --git a/paddler_agent/src/normalization/rms_norm.rs b/paddler_agent_embeddings/src/normalization/rms_norm.rs similarity index 100% rename from paddler_agent/src/normalization/rms_norm.rs rename to paddler_agent_embeddings/src/normalization/rms_norm.rs diff --git a/paddler_agent/src/plan_embedding_batches.rs b/paddler_agent_embeddings/src/plan_embedding_batches.rs similarity index 100% rename from paddler_agent/src/plan_embedding_batches.rs rename to paddler_agent_embeddings/src/plan_embedding_batches.rs diff --git a/paddler_agent_embeddings/src/prepared_embedding_batch_request.rs b/paddler_agent_embeddings/src/prepared_embedding_batch_request.rs new file mode 100644 index 000000000..193bf2efd --- /dev/null +++ b/paddler_agent_embeddings/src/prepared_embedding_batch_request.rs @@ -0,0 +1,63 @@ +use tokio::sync::mpsc; + +use paddler_agent_runtime::scheduler_command::SchedulerCommand; +use paddler_agent_status::slot_guard::SlotGuard; +use paddler_messaging::embedding_normalization_method::EmbeddingNormalizationMethod; +use paddler_messaging::embedding_result::EmbeddingResult; +use paddler_messaging::oversized_embedding_document_details::OversizedEmbeddingDocumentDetails; + +use crate::embedding_error::EmbeddingError; +use crate::embedding_input_tokenized::EmbeddingInputTokenized; + +pub struct PreparedEmbeddingBatchRequest { + pub generate_embedding_stop_rx: mpsc::UnboundedReceiver<()>, + pub generated_embedding_tx: mpsc::UnboundedSender, + pub inputs: Vec, + pub normalization_method: EmbeddingNormalizationMethod, + pub oversized_documents: Vec, + pub slot_guard: SlotGuard, +} + +impl SchedulerCommand for PreparedEmbeddingBatchRequest { + fn reject_because_the_scheduler_stopped(self, agent_name: Option<&str>) { + EmbeddingError::SchedulerUnavailable.report(agent_name, &self.generated_embedding_tx); + } +} + +#[cfg(test)] +mod tests { + use std::sync::Arc; + + use tokio::sync::mpsc; + + use paddler_agent_runtime::scheduler_command::SchedulerCommand as _; + use paddler_agent_status::slot_aggregated_status::SlotAggregatedStatus; + use paddler_agent_status::slot_guard::SlotGuard; + use paddler_messaging::embedding_normalization_method::EmbeddingNormalizationMethod; + use paddler_messaging::embedding_result::EmbeddingResult; + + use super::PreparedEmbeddingBatchRequest; + + #[test] + fn rejects_an_embedding_batch_when_the_scheduler_stopped() { + let (generated_embedding_tx, mut generated_embedding_rx) = mpsc::unbounded_channel(); + let (_generate_embedding_stop_tx, generate_embedding_stop_rx) = mpsc::unbounded_channel(); + + PreparedEmbeddingBatchRequest { + generate_embedding_stop_rx, + generated_embedding_tx, + inputs: Vec::new(), + normalization_method: EmbeddingNormalizationMethod::None, + oversized_documents: Vec::new(), + slot_guard: SlotGuard::new(Arc::new(SlotAggregatedStatus::new(1))), + } + .reject_because_the_scheduler_stopped(Some("agent")); + + assert_eq!( + generated_embedding_rx.try_recv(), + Ok(EmbeddingResult::SchedulerUnavailable( + "agent: the scheduler is no longer accepting requests".to_owned() + )) + ); + } +} diff --git a/paddler_agent_embeddings/src/sequenced_embedding_input.rs b/paddler_agent_embeddings/src/sequenced_embedding_input.rs new file mode 100644 index 000000000..11adee2c2 --- /dev/null +++ b/paddler_agent_embeddings/src/sequenced_embedding_input.rs @@ -0,0 +1,6 @@ +use crate::embedding_input_tokenized::EmbeddingInputTokenized; + +pub struct SequencedEmbeddingInput<'inputs> { + pub input: &'inputs EmbeddingInputTokenized, + pub sequence_id: i32, +} diff --git a/paddler_agent_pointer_head/Cargo.toml b/paddler_agent_pointer_head/Cargo.toml new file mode 100644 index 000000000..03260673f --- /dev/null +++ b/paddler_agent_pointer_head/Cargo.toml @@ -0,0 +1,27 @@ +[package] +name = "paddler_agent_pointer_head" +autotests = false +authors.workspace = true +description.workspace = true +edition.workspace = true +homepage.workspace = true +license.workspace = true +repository.workspace = true +version.workspace = true + +[[test]] +name = "integration" +path = "tests/main.rs" + +[dependencies] +llama-cpp-bindings = { workspace = true } +thiserror = { workspace = true } + +[features] +default = [] +cuda = ["llama-cpp-bindings/cuda"] +metal = ["llama-cpp-bindings/metal"] +vulkan = ["llama-cpp-bindings/vulkan"] + +[lints] +workspace = true diff --git a/paddler_agent_pointer_head/src/lib.rs b/paddler_agent_pointer_head/src/lib.rs new file mode 100644 index 000000000..93ebef5f7 --- /dev/null +++ b/paddler_agent_pointer_head/src/lib.rs @@ -0,0 +1,4 @@ +pub mod pointer_head; +pub mod pointer_head_delimiters; +pub mod pointer_head_error; +pub mod pointer_head_projection; diff --git a/paddler_agent_pointer_head/src/pointer_head.rs b/paddler_agent_pointer_head/src/pointer_head.rs new file mode 100644 index 000000000..172052de3 --- /dev/null +++ b/paddler_agent_pointer_head/src/pointer_head.rs @@ -0,0 +1,206 @@ +use std::path::Path; + +use llama_cpp_bindings::gguf_context::GgufContext; +use llama_cpp_bindings::gguf_tensor_f32::GgufTensorF32; + +use crate::pointer_head_delimiters::PointerHeadDelimiters; +use crate::pointer_head_error::PointerHeadError; +use crate::pointer_head_projection::PointerHeadProjection; + +const TEMPERATURE_KEY: &str = "pointer_head.temperature"; +const TENSORS: [&str; 4] = [ + "pointer_head.key.bias", + "pointer_head.key.weight", + "pointer_head.query.bias", + "pointer_head.query.weight", +]; + +fn read_temperature(gguf_context: &GgufContext) -> Result { + let temperature = gguf_context + .find_key(TEMPERATURE_KEY) + .and_then(|key_id| gguf_context.val_f32(key_id)) + .map_err(|source| PointerHeadError::KeyUnreadable { + key: TEMPERATURE_KEY.to_owned(), + source, + })?; + + if temperature.is_finite() && temperature > 0.0 { + Ok(temperature) + } else { + Err(PointerHeadError::TemperatureOutOfRange { temperature }) + } +} + +fn softmax(logits: &[f32]) -> Vec { + let largest_logit = logits.iter().copied().fold(f32::NEG_INFINITY, f32::max); + let exponentials: Vec = logits + .iter() + .map(|logit| (logit - largest_logit).exp()) + .collect(); + let exponential_sum: f32 = exponentials.iter().sum(); + + exponentials + .iter() + .map(|exponential| exponential / exponential_sum) + .collect() +} + +pub struct PointerHead { + pub delimiters: PointerHeadDelimiters, + pub key_projection: PointerHeadProjection, + pub logit_scale: f32, + pub query_projection: PointerHeadProjection, + pub temperature: f32, +} + +impl PointerHead { + pub fn load(path: &Path) -> Result { + let gguf_context = + GgufContext::from_file(path).map_err(|source| PointerHeadError::FileUnreadable { + path: path.to_path_buf(), + source, + })?; + let temperature = read_temperature(&gguf_context)?; + let delimiters = PointerHeadDelimiters::load(&gguf_context)?; + let mut tensor_shapes = [[0; 4]; 4]; + let mut tensor_values: [Vec; 4] = Default::default(); + + for ((tensor, tensor_shape), tensor_value) in TENSORS + .iter() + .zip(&mut tensor_shapes) + .zip(&mut tensor_values) + { + let GgufTensorF32 { shape, values } = + gguf_context.read_tensor_f32(tensor).map_err(|source| { + PointerHeadError::TensorUnreadable { + tensor: (*tensor).to_owned(), + source, + } + })?; + + *tensor_shape = shape; + *tensor_value = values; + } + + let [key_bias_shape, key_weight_shape, _, _] = tensor_shapes; + let head_dim = key_bias_shape[0]; + let hidden_size = key_weight_shape[0]; + let bias_shape = [head_dim, 1, 1, 1]; + let weight_shape = [hidden_size, head_dim, 1, 1]; + + for ((tensor, actual), expected) in TENSORS.iter().zip(tensor_shapes).zip([ + bias_shape, + weight_shape, + bias_shape, + weight_shape, + ]) { + if actual != expected { + return Err(PointerHeadError::TensorShapeMismatch { + tensor: (*tensor).to_owned(), + expected, + actual, + }); + } + } + + let [key_bias, key_weight, query_bias, query_weight] = tensor_values; + + #[expect( + clippy::cast_precision_loss, + reason = "pointer head dims stay far below 2^24, the largest integer every f32 holds exactly" + )] + let logit_scale = (head_dim as f32).sqrt().recip(); + + Ok(Self { + delimiters, + key_projection: PointerHeadProjection { + bias: key_bias, + hidden_size: hidden_size as usize, + weight: key_weight, + }, + logit_scale, + query_projection: PointerHeadProjection { + bias: query_bias, + hidden_size: hidden_size as usize, + weight: query_weight, + }, + temperature, + }) + } + + #[must_use] + pub const fn hidden_size(&self) -> usize { + self.key_projection.hidden_size + } + + #[must_use] + pub fn option_probabilities(&self, output_hidden_states: &[Vec]) -> Vec { + let Some((decide_hidden_state, option_hidden_states)) = output_hidden_states.split_last() + else { + return Vec::new(); + }; + let query = self.query_projection.project(decide_hidden_state); + + softmax( + &option_hidden_states + .iter() + .map(|option_hidden_state| { + let key = self.key_projection.project(option_hidden_state); + let alignment: f32 = key + .iter() + .zip(&query) + .map(|(key_value, query_value)| key_value * query_value) + .sum(); + + alignment * self.logit_scale / self.temperature + }) + .collect::>(), + ) + } +} + +#[cfg(test)] +mod tests { + use super::PointerHead; + use crate::pointer_head_delimiters::PointerHeadDelimiters; + use crate::pointer_head_projection::PointerHeadProjection; + + fn identity_projection() -> PointerHeadProjection { + PointerHeadProjection { + bias: vec![0.0, 0.0], + hidden_size: 2, + weight: vec![1.0, 0.0, 0.0, 1.0], + } + } + + fn pointer_head() -> PointerHead { + PointerHead { + delimiters: PointerHeadDelimiters { + decide: "".to_owned(), + option_end: "".to_owned(), + option_start: "

  • + + {model_path.model_path} is a {architecture} model, which cannot + serve decisions + + What can you do?{" "} +

    + Change the model to a Qwen3.5 + decision model. +

    +
  • + ); + } + + if ("PointerHeadCannotBeLoaded" in issue) { + return ( +
  • + + Pointer head cannot be loaded:{" "} + {issue.PointerHeadCannotBeLoaded.model_path} + + What can you do?{" "} +

    + Either ensure that the file is a pointer head available to the + agent, or change the pointer head. +

    +
  • + ); + } + + if ("PointerHeadIncompatibleWithModel" in issue) { + const { incompatibility, pointer_head_path } = + issue.PointerHeadIncompatibleWithModel; + + return ( +
  • + + Pointer head {pointer_head_path.model_path} does not fit the + model:{" "} + {"HiddenSizeMismatch" in incompatibility + ? `it reads ${incompatibility.HiddenSizeMismatch.pointer_head_hidden_size}-dimensional hidden states, the model produces ${incompatibility.HiddenSizeMismatch.model_hidden_size}` + : `its delimiter ${incompatibility.DelimiterIsNotAControlToken.delimiter} is not a control token of the model`} + + What can you do?{" "} +

    + Use the pointer head that was + converted together with the model. +

    +
  • + ); + } + + if ("SlotsInsufficientForDecisions" in issue) { + const { desired_slots, required_slots } = + issue.SlotsInsufficientForDecisions; + + return ( +
  • + + Decisions need at least {required_slots} slots, the agent runs{" "} + {desired_slots} + + What can you do?{" "} +

    + Restart the agent with --slots {required_slots} or + more. +

    +
  • + ); + } + return (
  • Unknown issue: {JSON.stringify(issue)} diff --git a/resources/ts/components/AgentListAgentStatus.tsx b/resources/ts/components/AgentListAgentStatus.tsx index d26fce510..5b92993c4 100644 --- a/resources/ts/components/AgentListAgentStatus.tsx +++ b/resources/ts/components/AgentListAgentStatus.tsx @@ -7,11 +7,13 @@ import { agentListAgentStatus__progress } from "./AgentListAgentStatus.module.cs export function AgentListAgentStatus({ agent: { slots_processing, - status: { desired_slots_total, slots_total, state_application_status }, + status: { desired_slots_total, runtime, state_application_status }, }, }: { agent: Agent; }) { + const slots_total = runtime === "Idle" ? 0 : runtime.Serving.slots_total; + switch (state_application_status) { case "Applied": return ( diff --git a/resources/ts/components/BalancerDesiredStateContextProvider.tsx b/resources/ts/components/BalancerDesiredStateContextProvider.tsx new file mode 100644 index 000000000..11fc55c09 --- /dev/null +++ b/resources/ts/components/BalancerDesiredStateContextProvider.tsx @@ -0,0 +1,34 @@ +import React, { useMemo, useState, type ReactNode } from "react"; + +import { type BalancerDesiredState } from "@intentee/paddler-client/schemas/BalancerDesiredState"; +import { + BalancerDesiredStateContext, + type BalancerDesiredStateContextValue, +} from "../contexts/BalancerDesiredStateContext"; + +export function BalancerDesiredStateContextProvider({ + children, + defaultBalancerDesiredState, +}: { + children: ReactNode; + defaultBalancerDesiredState: BalancerDesiredState; +}) { + const [balancerDesiredState, setBalancerDesiredState] = + useState(defaultBalancerDesiredState); + + const value = useMemo( + function () { + return Object.freeze({ + balancerDesiredState, + setBalancerDesiredState, + }); + }, + [balancerDesiredState, setBalancerDesiredState], + ); + + return ( + + {children} + + ); +} diff --git a/resources/ts/components/ChangeModelForm.tsx b/resources/ts/components/ChangeModelForm.tsx index 162144a70..89ab7a883 100644 --- a/resources/ts/components/ChangeModelForm.tsx +++ b/resources/ts/components/ChangeModelForm.tsx @@ -7,16 +7,16 @@ import React, { } from "react"; import { useLocation } from "wouter"; +import { type AgentDesiredModel } from "@intentee/paddler-client/schemas/AgentDesiredModel"; import { type BalancerDesiredState } from "@intentee/paddler-client/schemas/BalancerDesiredState"; -import { ChatTemplateContext } from "../contexts/ChatTemplateContext"; -import { InferenceParametersContext } from "../contexts/InferenceParametersContext"; +import { type BalancerInferenceSettings } from "@intentee/paddler-client/schemas/BalancerInferenceSettings"; +import { BalancerDesiredStateContext } from "../contexts/BalancerDesiredStateContext"; import { PaddlerConfigurationContext } from "../contexts/PaddlerConfigurationContext"; import { useAgentDesiredModelUrl } from "../hooks/useAgentDesiredModelUrl"; import { ChatTemplateBehavior } from "./ChatTemplateBehavior"; -import { InferenceParameterCacheDtype } from "./InferenceParameterCacheDtype"; -import { InferenceParameterCheckbox } from "./InferenceParameterCheckbox"; -import { InferenceParameterInput } from "./InferenceParameterInput"; -import { InferenceParameterPoolingType } from "./InferenceParameterPoolingType"; +import { EmbeddingParametersFields } from "./EmbeddingParametersFields"; +import { ModelRuntimeParametersFields } from "./ModelRuntimeParametersFields"; +import { TextGenerationSettingsFields } from "./TextGenerationSettingsFields"; import { changeModelForm, @@ -33,18 +33,57 @@ import { changeModelForm__submitButton, } from "./ChangeModelForm.module.css"; +function withMultimodalProjection( + inferenceSettings: BalancerInferenceSettings, + projection: AgentDesiredModel, +): BalancerInferenceSettings { + if (!("TextGeneration" in inferenceSettings)) { + return inferenceSettings; + } + + return { + TextGeneration: { + ...inferenceSettings.TextGeneration, + multimodal: { + ...inferenceSettings.TextGeneration.multimodal, + projection, + }, + }, + }; +} + +function withPointerHead( + inferenceSettings: BalancerInferenceSettings, + pointerHead: AgentDesiredModel, +): BalancerInferenceSettings { + if (!("Decision" in inferenceSettings)) { + return inferenceSettings; + } + + return { + Decision: { + ...inferenceSettings.Decision, + pointer_head: pointerHead, + }, + }; +} + export function ChangeModelForm({ defaultBaseModelUri, defaultMultimodalProjectionUri, + defaultPointerHeadUri, }: { defaultBaseModelUri: null | string; defaultMultimodalProjectionUri: null | string; + defaultPointerHeadUri: null | string; }) { const [, navigate] = useLocation(); - const { chatTemplateOverride, useChatTemplateOverride } = - useContext(ChatTemplateContext); - const { parameters } = useContext(InferenceParametersContext); - const { managementAddr } = useContext(PaddlerConfigurationContext); + const { balancerDesiredState: editedDesiredState } = useContext( + BalancerDesiredStateContext, + ); + const { inferenceMode, managementAddr } = useContext( + PaddlerConfigurationContext, + ); const { agentDesiredModelState: baseModelAgentDesiredModelState, modelUri: baseModelUri, @@ -59,6 +98,13 @@ export function ChangeModelForm({ } = useAgentDesiredModelUrl({ defaultModelUri: defaultMultimodalProjectionUri, }); + const { + agentDesiredModelState: pointerHeadAgentDesiredModelState, + modelUri: pointerHeadModelUri, + setModelUri: setPointerHeadModelUri, + } = useAgentDesiredModelUrl({ + defaultModelUri: defaultPointerHeadUri, + }); const onBaseModelUriInput = useCallback( function (event: InputEvent) { @@ -74,32 +120,42 @@ export function ChangeModelForm({ [setMultimodalProjectionModelUri], ); + const onPointerHeadUriInput = useCallback( + function (event: InputEvent) { + setPointerHeadModelUri(event.currentTarget.value); + }, + [setPointerHeadModelUri], + ); + const balancerDesiredState: null | BalancerDesiredState = useMemo( function () { if ( !baseModelAgentDesiredModelState.ok || - !multimodalProjecttionAgentDesiredModelState.ok + !multimodalProjecttionAgentDesiredModelState.ok || + !pointerHeadAgentDesiredModelState.ok ) { return null; } const desiredState: BalancerDesiredState = Object.freeze({ - chat_template_override: chatTemplateOverride, - inference_parameters: parameters, + ...editedDesiredState, + inference_settings: withPointerHead( + withMultimodalProjection( + editedDesiredState.inference_settings, + multimodalProjecttionAgentDesiredModelState.agentDesiredModel, + ), + pointerHeadAgentDesiredModelState.agentDesiredModel, + ), model: baseModelAgentDesiredModelState.agentDesiredModel, - multimodal_projection: - multimodalProjecttionAgentDesiredModelState.agentDesiredModel, - use_chat_template_override: useChatTemplateOverride, }); return desiredState; }, [ baseModelAgentDesiredModelState, - chatTemplateOverride, + editedDesiredState, multimodalProjecttionAgentDesiredModelState, - parameters, - useChatTemplateOverride, + pointerHeadAgentDesiredModelState, ], ); @@ -196,111 +252,72 @@ export function ChangeModelForm({ value={String(baseModelUri)} /> - -
    - Chat Template - -
    + {inferenceMode === "Decision" && ( + + )} + {inferenceMode === "TextGeneration" && ( + <> + +
    + Chat Template + +
    + + )}
    - Inference Parameters + Model Parameters
    What are these parameters?

    - These parameters control how the model behaves during inference. - They can affect the quality, speed, and memory usage of the - model. + These parameters control how the model is loaded and how much + memory it uses. In text generation, the context is shared + between the slots of each agent.

    They are usually model-specific and are usually provided by the model authors, although Paddler provides some reasonable defaults.

    -

    - Experimenting with these settings is worth exploring to optimize - performance for your specific needs. -

    - - - - - - - - - - - - - - - - - +
    + {inferenceMode === "TextGeneration" && ( +
    + Text Generation Parameters + +
    + )} + {inferenceMode === "Embeddings" && ( +
    + Embedding Parameters + +
    + )}
    diff --git a/resources/ts/components/ConversationPromptInputImageButton.tsx b/resources/ts/components/ConversationPromptInputImageButton.tsx index 501a5f2e1..5a86f15be 100644 --- a/resources/ts/components/ConversationPromptInputImageButton.tsx +++ b/resources/ts/components/ConversationPromptInputImageButton.tsx @@ -6,7 +6,6 @@ import React, { } from "react"; import { PromptImageContext } from "../contexts/PromptImageContext"; -import { TokenGenerationDisabledContext } from "../contexts/TokenGenerationDisabledContext"; import { conversationPromptInput__button } from "./ConversationPromptInput.module.css"; import { conversationPromptInputImageButton__fileInput } from "./ConversationPromptInputImageButton.module.css"; @@ -35,9 +34,6 @@ function readFileAsDataUri(imageFile: File): Promise { export function ConversationPromptInputImageButton() { const { setCurrentImageDataUri } = useContext(PromptImageContext); - const { isTokenGenerationDisabled } = useContext( - TokenGenerationDisabledContext, - ); const fileInputRef = useRef(null); const onButtonClick = useCallback(function () { @@ -64,14 +60,12 @@ export function ConversationPromptInputImageButton() {
    {statsd !== null && (
    ( + name: TKey, + value: EmbeddingParameters[TKey], + ) { + setBalancerDesiredState({ + ...balancerDesiredState, + inference_settings: { + Embeddings: { + ...embeddingParameters, + [name]: value, + }, + }, + }); + }, + [balancerDesiredState, embeddingParameters, setBalancerDesiredState], + ); + + return ( + <> + + + + ); +} diff --git a/resources/ts/components/Home.tsx b/resources/ts/components/Home.tsx index 46b3db83b..f4d18460b 100644 --- a/resources/ts/components/Home.tsx +++ b/resources/ts/components/Home.tsx @@ -1,6 +1,7 @@ -import React from "react"; +import React, { useContext } from "react"; import { Route, Router, Switch } from "wouter"; +import { PaddlerConfigurationContext } from "../contexts/PaddlerConfigurationContext"; import { ChangeModelPage } from "./ChangeModelPage"; import { DashboardPage } from "./DashboardPage"; import { PromptContextProvider } from "./PromptContextProvider"; @@ -10,6 +11,8 @@ import { PromptThinkingContextProvider } from "./PromptThinkingContextProvider"; import { WorkbenchLayout } from "./WorkbenchLayout"; export function Home() { + const { inferenceMode } = useContext(PaddlerConfigurationContext); + return ( @@ -20,15 +23,17 @@ export function Home() { - - - - - - - - - + {inferenceMode === "TextGeneration" && ( + + + + + + + + + + )} 404 :( diff --git a/resources/ts/components/InferenceParameterCacheDtype.tsx b/resources/ts/components/InferenceParameterCacheDtype.tsx deleted file mode 100644 index 70155109f..000000000 --- a/resources/ts/components/InferenceParameterCacheDtype.tsx +++ /dev/null @@ -1,55 +0,0 @@ -import React, { useCallback, useContext, type ChangeEvent } from "react"; - -import { cacheDtypes } from "@intentee/paddler-client/schemas/InferenceParameters"; -import { InferenceParametersContext } from "../contexts/InferenceParametersContext"; -import { - inferenceParameterInput, - inferenceParameterInput__label, - inferenceParameterInput__select, -} from "./inferenceParameterInput.module.css"; - -function isCacheDtype(value: string): value is (typeof cacheDtypes)[number] { - return cacheDtypes.includes(value as (typeof cacheDtypes)[number]); -} - -export function InferenceParameterCacheDtype({ - description, - name, -}: { - description: string; - name: "k_cache_dtype" | "v_cache_dtype"; -}) { - const { parameters, setParameter } = useContext(InferenceParametersContext); - - const onChange = useCallback( - function (event: ChangeEvent) { - const option = event.currentTarget.value; - - if (!isCacheDtype(option)) { - throw new Error(`Invalid cache dtype: ${option}`); - } - - setParameter(name, option); - }, - [name, setParameter], - ); - - return ( - - ); -} diff --git a/resources/ts/components/InferenceParameterCheckbox.tsx b/resources/ts/components/InferenceParameterCheckbox.tsx deleted file mode 100644 index 3e251ef78..000000000 --- a/resources/ts/components/InferenceParameterCheckbox.tsx +++ /dev/null @@ -1,42 +0,0 @@ -import React, { useCallback, useContext } from "react"; - -import { InferenceParametersContext } from "../contexts/InferenceParametersContext"; -import { type InferenceParametersBooleanKeys } from "../inferenceParametersFormKeys"; -import { - inferenceParameterInput, - inferenceParameterInput__checkbox, - inferenceParameterInput__label, -} from "./inferenceParameterInput.module.css"; - -export function InferenceParameterCheckbox({ - description, - name, -}: { - description: string; - name: InferenceParametersBooleanKeys; -}) { - const { parameters, setParameter } = useContext(InferenceParametersContext); - - const onChange = useCallback( - function () { - setParameter(name, !parameters[name]); - }, - [name, parameters, setParameter], - ); - - return ( - - ); -} diff --git a/resources/ts/components/InferenceParameterPoolingType.tsx b/resources/ts/components/InferenceParameterPoolingType.tsx deleted file mode 100644 index 10f280721..000000000 --- a/resources/ts/components/InferenceParameterPoolingType.tsx +++ /dev/null @@ -1,71 +0,0 @@ -import React, { useCallback, useContext, type ChangeEvent } from "react"; - -import { poolingTypes } from "@intentee/paddler-client/schemas/InferenceParameters"; -import { InferenceParametersContext } from "../contexts/InferenceParametersContext"; -import { - inferenceParameterInput, - inferenceParameterInput__disabledHint, - inferenceParameterInput__disabledHint__content, - inferenceParameterInput__label, - inferenceParameterInput__select, -} from "./inferenceParameterInput.module.css"; - -const name = "pooling_type"; - -function isPoolingType(value: string): value is (typeof poolingTypes)[number] { - return poolingTypes.includes(value as (typeof poolingTypes)[number]); -} - -export function InferenceParameterPoolingType({ - description, - disabled, -}: { - description: string; - disabled: boolean; -}) { - const { parameters, setParameter } = useContext(InferenceParametersContext); - - const onChange = useCallback( - function (event: ChangeEvent) { - const option = event.currentTarget.value; - - if (!isPoolingType(option)) { - throw new Error(`Invalid pooling type: ${option}`); - } - - setParameter(name, option); - }, - [setParameter], - ); - - return ( - - ); -} diff --git a/resources/ts/components/InferenceParametersContextProvider.tsx b/resources/ts/components/InferenceParametersContextProvider.tsx deleted file mode 100644 index b67e2ed4d..000000000 --- a/resources/ts/components/InferenceParametersContextProvider.tsx +++ /dev/null @@ -1,57 +0,0 @@ -import React, { useMemo, useState, type ReactNode } from "react"; - -import { type InferenceParameters } from "@intentee/paddler-client/schemas/InferenceParameters"; -import { - InferenceParametersContext, - type InferenceParametersContextValue, -} from "../contexts/InferenceParametersContext"; - -export function InferenceParametersContextProvider({ - children, - defaultInferenceParameters, -}: { - children: ReactNode; - defaultInferenceParameters: InferenceParameters; -}) { - const [parameters, setParameters] = useState( - defaultInferenceParameters, - ); - - const value = useMemo( - function () { - function setPartialParameters( - partialParameters: Partial, - ) { - setParameters({ - ...parameters, - ...partialParameters, - }); - } - - function setParameter( - name: TKey, - value: InferenceParameters[TKey], - ) { - setPartialParameters({ - ...parameters, - ...{ - [name]: value, - }, - }); - } - - return Object.freeze({ - parameters, - setParameter, - setPartialParameters, - }); - }, - [parameters, setParameters], - ); - - return ( - - {children} - - ); -} diff --git a/resources/ts/components/ModelRuntimeParametersFields.tsx b/resources/ts/components/ModelRuntimeParametersFields.tsx new file mode 100644 index 000000000..5fc990947 --- /dev/null +++ b/resources/ts/components/ModelRuntimeParametersFields.tsx @@ -0,0 +1,79 @@ +import React, { useCallback, useContext } from "react"; + +import { + cacheDtypes, + type ModelRuntimeParameters, +} from "@intentee/paddler-client/schemas/ModelRuntimeParameters"; +import { BalancerDesiredStateContext } from "../contexts/BalancerDesiredStateContext"; +import { ParameterNumberInput } from "./ParameterNumberInput"; +import { ParameterSelect } from "./ParameterSelect"; + +export function ModelRuntimeParametersFields() { + const { balancerDesiredState, setBalancerDesiredState } = useContext( + BalancerDesiredStateContext, + ); + const modelRuntimeParameters = balancerDesiredState.model_runtime_parameters; + + const setParameter = useCallback( + function ( + name: TKey, + value: ModelRuntimeParameters[TKey], + ) { + setBalancerDesiredState({ + ...balancerDesiredState, + model_runtime_parameters: { + ...balancerDesiredState.model_runtime_parameters, + [name]: value, + }, + }); + }, + [balancerDesiredState, setBalancerDesiredState], + ); + + return ( + <> + + + + + + + ); +} diff --git a/resources/ts/components/InferenceParameterInput.tsx b/resources/ts/components/ParameterNumberInput.tsx similarity index 56% rename from resources/ts/components/InferenceParameterInput.tsx rename to resources/ts/components/ParameterNumberInput.tsx index 0f6fd5246..52a79617e 100644 --- a/resources/ts/components/InferenceParameterInput.tsx +++ b/resources/ts/components/ParameterNumberInput.tsx @@ -1,29 +1,29 @@ -import React, { useCallback, useContext, type FormEvent } from "react"; +import React, { useCallback, type FormEvent } from "react"; -import { InferenceParametersContext } from "../contexts/InferenceParametersContext"; -import { type InferenceParametersNumberKeys } from "../inferenceParametersFormKeys"; import { inferenceParameterInput, inferenceParameterInput__input, inferenceParameterInput__label, } from "./inferenceParameterInput.module.css"; -export function InferenceParameterInput({ +export function ParameterNumberInput({ description, name, + onValue, + value, }: { description: string; - name: InferenceParametersNumberKeys; + name: string; + onValue(this: void, value: number): void; + value: number; }) { - const { parameters, setParameter } = useContext(InferenceParametersContext); - const onInput = useCallback( function (event: FormEvent) { event.preventDefault(); - setParameter(name, parseFloat(event.currentTarget.value)); + onValue(parseFloat(event.currentTarget.value)); }, - [name, setParameter], + [onValue], ); return ( @@ -37,7 +37,7 @@ export function InferenceParameterInput({ onInput={onInput} required type="number" - value={parameters[name]} + value={value} /> ); diff --git a/resources/ts/components/ParameterSelect.tsx b/resources/ts/components/ParameterSelect.tsx new file mode 100644 index 000000000..1be4b2af6 --- /dev/null +++ b/resources/ts/components/ParameterSelect.tsx @@ -0,0 +1,55 @@ +import React, { useCallback, type ChangeEvent } from "react"; + +import { + inferenceParameterInput, + inferenceParameterInput__label, + inferenceParameterInput__select, +} from "./inferenceParameterInput.module.css"; + +export function ParameterSelect({ + description, + name, + onValue, + options, + value, +}: { + description: string; + name: string; + onValue(this: void, value: TOption): void; + options: ReadonlyArray; + value: TOption; +}) { + const onChange = useCallback( + function (event: ChangeEvent) { + const selectedOption = options.find(function (option) { + return option === event.currentTarget.value; + }); + + if (selectedOption === undefined) { + throw new Error(`Invalid ${name}: ${event.currentTarget.value}`); + } + + onValue(selectedOption); + }, + [name, onValue, options], + ); + + return ( + + ); +} diff --git a/resources/ts/components/PromptPage.tsx b/resources/ts/components/PromptPage.tsx index a1f74e97b..e3a33d890 100644 --- a/resources/ts/components/PromptPage.tsx +++ b/resources/ts/components/PromptPage.tsx @@ -9,8 +9,6 @@ import { ConversationMessage } from "./ConversationMessage"; import { ConversationMessagePromptGeneratedTokens } from "./ConversationMessagePromptGeneratedTokens"; import { ConversationPromptInput } from "./ConversationPromptInput"; import { FloatingStatus } from "./FloatingStatus"; -import { TokenGenerationDisabledContextProvider } from "./TokenGenerationDisabledContextProvider"; -import { TokenGenerationDisabledNotice } from "./TokenGenerationDisabledNotice"; import { promptPage, @@ -21,61 +19,50 @@ import { export function PromptPage() { const { inferenceAddr } = useContext(PaddlerConfigurationContext); const { submittedPrompt } = useContext(PromptContext); - const { socketState, webSocket } = useWebSocket({ + const { socketState } = useWebSocket({ endpoint: `${webSocketProtocol(window.location.protocol)}//${inferenceAddr}/api/v1/inference_socket`, }); - return ( - - {matchWebSocketState(socketState, { - connected({ webSocket }) { - return ( -
    -
    - {submittedPrompt && ( - - )} - -
    -
    - - -
    -
    - ); - }, - connecting() { - return ( - - Connecting to the inference server... - - ); - }, - connectionClosed() { - return ( - - Connection to the inference server closed. Will try to - reconnect... - - ); - }, - connectionError() { - return ( - - Cannot connect to the inference server. Will try again in a - moment... - - ); - }, - })} -
    - ); + return matchWebSocketState(socketState, { + connected({ webSocket }) { + return ( +
    +
    + {submittedPrompt && ( + + )} + +
    +
    + +
    +
    + ); + }, + connecting() { + return ( + Connecting to the inference server... + ); + }, + connectionClosed() { + return ( + + Connection to the inference server closed. Will try to reconnect... + + ); + }, + connectionError() { + return ( + + Cannot connect to the inference server. Will try again in a moment... + + ); + }, + }); } diff --git a/resources/ts/components/TextGenerationSettingsFields.tsx b/resources/ts/components/TextGenerationSettingsFields.tsx new file mode 100644 index 000000000..9fdd43c30 --- /dev/null +++ b/resources/ts/components/TextGenerationSettingsFields.tsx @@ -0,0 +1,99 @@ +import React, { useCallback, useContext } from "react"; + +import { type SamplingParameters } from "@intentee/paddler-client/schemas/SamplingParameters"; +import { BalancerDesiredStateContext } from "../contexts/BalancerDesiredStateContext"; +import { textGenerationSettingsOf } from "../textGenerationSettingsOf"; +import { ParameterNumberInput } from "./ParameterNumberInput"; + +const samplingParameterDescriptions: ReadonlyArray<{ + description: string; + name: keyof SamplingParameters; +}> = [ + { + description: "Minimum token probability to consider for selection", + name: "min_p", + }, + { description: "Frequency Penalty", name: "penalty_frequency" }, + { + description: "Number of last tokens to consider for penalty (0 = disabled)", + name: "penalty_last_n", + }, + { description: "Presence Penalty", name: "penalty_presence" }, + { description: "Repeated Token Penalty", name: "penalty_repeat" }, + { description: "Temperature", name: "temperature" }, + { + description: "Number of tokens to consider for selection", + name: "top_k", + }, + { + description: "Probability threshold for selecting tokens", + name: "top_p", + }, +]; + +export function TextGenerationSettingsFields() { + const { balancerDesiredState, setBalancerDesiredState } = useContext( + BalancerDesiredStateContext, + ); + const textGenerationSettings = textGenerationSettingsOf(balancerDesiredState); + + const setSamplingParameter = useCallback( + function (name: keyof SamplingParameters, value: number) { + setBalancerDesiredState({ + ...balancerDesiredState, + inference_settings: { + TextGeneration: { + ...textGenerationSettings, + sampling_parameters: { + ...textGenerationSettings.sampling_parameters, + [name]: value, + }, + }, + }, + }); + }, + [balancerDesiredState, setBalancerDesiredState, textGenerationSettings], + ); + + const setImageResizeToFit = useCallback( + function (imageResizeToFit: number) { + setBalancerDesiredState({ + ...balancerDesiredState, + inference_settings: { + TextGeneration: { + ...textGenerationSettings, + multimodal: { + ...textGenerationSettings.multimodal, + image_resize_to_fit: imageResizeToFit, + }, + }, + }, + }); + }, + [balancerDesiredState, setBalancerDesiredState, textGenerationSettings], + ); + + return ( + <> + + {samplingParameterDescriptions.map(function ({ description, name }) { + return ( + + ); + })} + + ); +} diff --git a/resources/ts/components/TokenGenerationDisabledContextProvider.tsx b/resources/ts/components/TokenGenerationDisabledContextProvider.tsx deleted file mode 100644 index f9d9e097b..000000000 --- a/resources/ts/components/TokenGenerationDisabledContextProvider.tsx +++ /dev/null @@ -1,57 +0,0 @@ -import React, { useEffect, useMemo, useState, type ReactNode } from "react"; - -import { inferenceSocketClient } from "@intentee/paddler-client/inferenceSocketClient"; -import { - TokenGenerationDisabledContext, - type TokenGenerationDisabledContextValue, -} from "../contexts/TokenGenerationDisabledContext"; - -export function TokenGenerationDisabledContextProvider({ - children, - webSocket, -}: { - children: ReactNode; - webSocket: null | WebSocket; -}) { - const [isTokenGenerationDisabled, setIsTokenGenerationDisabled] = - useState(false); - - useEffect( - function () { - setIsTokenGenerationDisabled(false); - - if (!webSocket) { - return; - } - - const socketClient = inferenceSocketClient({ webSocket }); - const subscription = socketClient.clusterTokenGenerationMode$.subscribe( - function (notification) { - setIsTokenGenerationDisabled( - "TokenGenerationDisabled" === notification, - ); - }, - ); - - return function () { - subscription.unsubscribe(); - }; - }, - [webSocket], - ); - - const value = useMemo( - function () { - return Object.freeze({ - isTokenGenerationDisabled, - }); - }, - [isTokenGenerationDisabled], - ); - - return ( - - {children} - - ); -} diff --git a/resources/ts/components/TokenGenerationDisabledNotice.module.css b/resources/ts/components/TokenGenerationDisabledNotice.module.css deleted file mode 100644 index 3abedd742..000000000 --- a/resources/ts/components/TokenGenerationDisabledNotice.module.css +++ /dev/null @@ -1,8 +0,0 @@ -.tokenGenerationDisabledNotice { - background-color: var(--color-highlight-1); - border: 2px solid var(--color-border); - border-bottom: none; - box-shadow: 4px 4px 0 var(--color-border); - margin: 0 var(--spacing-half); - padding: var(--spacing-half) var(--spacing-base); -} diff --git a/resources/ts/components/TokenGenerationDisabledNotice.tsx b/resources/ts/components/TokenGenerationDisabledNotice.tsx deleted file mode 100644 index df7cf8fc1..000000000 --- a/resources/ts/components/TokenGenerationDisabledNotice.tsx +++ /dev/null @@ -1,29 +0,0 @@ -import React, { useContext } from "react"; - -import { TokenGenerationDisabledContext } from "../contexts/TokenGenerationDisabledContext"; - -import { tokenGenerationDisabledNotice } from "./TokenGenerationDisabledNotice.module.css"; - -export function TokenGenerationDisabledNotice() { - const { isTokenGenerationDisabled } = useContext( - TokenGenerationDisabledContext, - ); - - if (!isTokenGenerationDisabled) { - return null; - } - - return ( -
    - ); -} diff --git a/resources/ts/components/WorkbenchLayout.tsx b/resources/ts/components/WorkbenchLayout.tsx index 6baf27756..530ea9a82 100644 --- a/resources/ts/components/WorkbenchLayout.tsx +++ b/resources/ts/components/WorkbenchLayout.tsx @@ -1,7 +1,9 @@ import clsx from "clsx"; -import React, { ReactNode } from "react"; +import React, { ReactNode, useContext } from "react"; import { Link } from "wouter"; +import { PaddlerConfigurationContext } from "../contexts/PaddlerConfigurationContext"; + import { workbench, workbench__content, @@ -18,6 +20,8 @@ function activeClassName(isActive: boolean) { } export function WorkbenchLayout({ children }: { children: ReactNode }) { + const { inferenceMode } = useContext(PaddlerConfigurationContext); + return (
    @@ -33,9 +37,11 @@ export function WorkbenchLayout({ children }: { children: ReactNode }) { Model - - Prompt - + {inferenceMode === "TextGeneration" && ( + + Prompt + + )}
    {children}
    diff --git a/resources/ts/contexts/BalancerDesiredStateContext.ts b/resources/ts/contexts/BalancerDesiredStateContext.ts new file mode 100644 index 000000000..38f01d400 --- /dev/null +++ b/resources/ts/contexts/BalancerDesiredStateContext.ts @@ -0,0 +1,21 @@ +import { createContext } from "react"; + +import { type BalancerDesiredState } from "@intentee/paddler-client/schemas/BalancerDesiredState"; + +export type BalancerDesiredStateContextValue = { + balancerDesiredState: BalancerDesiredState; + setBalancerDesiredState( + this: void, + balancerDesiredState: BalancerDesiredState, + ): void; +}; + +export const BalancerDesiredStateContext = + createContext({ + get balancerDesiredState(): never { + throw new Error("BalancerDesiredStateContext not provided"); + }, + setBalancerDesiredState(): never { + throw new Error("BalancerDesiredStateContext not provided"); + }, + }); diff --git a/resources/ts/contexts/InferenceParametersContext.ts b/resources/ts/contexts/InferenceParametersContext.ts deleted file mode 100644 index 23e294590..000000000 --- a/resources/ts/contexts/InferenceParametersContext.ts +++ /dev/null @@ -1,29 +0,0 @@ -import { createContext } from "react"; - -import { type InferenceParameters } from "@intentee/paddler-client/schemas/InferenceParameters"; - -export type InferenceParametersContextValue = { - parameters: InferenceParameters; - setParameter( - this: void, - name: TKey, - value: InferenceParameters[TKey], - ): void; - setPartialParameters( - this: void, - parameters: Partial, - ): void; -}; - -export const InferenceParametersContext = - createContext({ - get parameters(): never { - throw new Error("InferenceParametersContext not provided"); - }, - setParameter(): never { - throw new Error("InferenceParametersContext not provided"); - }, - setPartialParameters(): never { - throw new Error("InferenceParametersContext not provided"); - }, - }); diff --git a/resources/ts/contexts/PaddlerConfigurationContext.ts b/resources/ts/contexts/PaddlerConfigurationContext.ts index 00f12224f..2bbfb630d 100644 --- a/resources/ts/contexts/PaddlerConfigurationContext.ts +++ b/resources/ts/contexts/PaddlerConfigurationContext.ts @@ -1,11 +1,14 @@ import { createContext } from "react"; +import { type InferenceMode } from "@intentee/paddler-client/schemas/InferenceMode"; import { type StatsdConfiguration } from "../StatsdConfiguration"; export type PaddlerConfigurationContextValue = { bufferedRequestTimeoutMillis: number; compatOpenAIAddr: string | null; + compatTypeSafeAddr: string | null; inferenceAddr: string; + inferenceMode: InferenceMode; managementAddr: string; maxBufferedRequests: number; statsd: StatsdConfiguration | null; @@ -19,9 +22,15 @@ export const PaddlerConfigurationContext = get compatOpenAIAddr(): never { throw new Error("PaddlerConfigurationContext not provided"); }, + get compatTypeSafeAddr(): never { + throw new Error("PaddlerConfigurationContext not provided"); + }, get inferenceAddr(): never { throw new Error("PaddlerConfigurationContext not provided"); }, + get inferenceMode(): never { + throw new Error("PaddlerConfigurationContext not provided"); + }, get managementAddr(): never { throw new Error("PaddlerConfigurationContext not provided"); }, diff --git a/resources/ts/contexts/TokenGenerationDisabledContext.ts b/resources/ts/contexts/TokenGenerationDisabledContext.ts deleted file mode 100644 index d3ef4d415..000000000 --- a/resources/ts/contexts/TokenGenerationDisabledContext.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { createContext } from "react"; - -export type TokenGenerationDisabledContextValue = { - isTokenGenerationDisabled: boolean; -}; - -export const TokenGenerationDisabledContext = - createContext({ - get isTokenGenerationDisabled(): never { - throw new Error("TokenGenerationDisabledContext not provided"); - }, - }); diff --git a/resources/ts/controller_dashboard.tsx b/resources/ts/controller_dashboard.tsx index be25ea7fe..70bf2d583 100644 --- a/resources/ts/controller_dashboard.tsx +++ b/resources/ts/controller_dashboard.tsx @@ -1,6 +1,7 @@ import React from "react"; import { createRoot } from "react-dom/client"; +import { InferenceModeSchema } from "@intentee/paddler-client/schemas/InferenceMode"; import { type StatsdConfiguration } from "./StatsdConfiguration"; import { Home } from "./components/Home"; import { PaddlerConfigurationContext } from "./contexts/PaddlerConfigurationContext"; @@ -61,7 +62,12 @@ root.render( ), compatOpenAIAddr: rootNode.getOptionalStringFromDataset("compatOpenaiAddr"), + compatTypeSafeAddr: + rootNode.getOptionalStringFromDataset("compatTypesafeAddr"), inferenceAddr: rootNode.getStringFromDataset("inferenceAddr"), + inferenceMode: InferenceModeSchema.parse( + rootNode.getStringFromDataset("inferenceMode"), + ), managementAddr: rootNode.getStringFromDataset("managementAddr"), maxBufferedRequests: rootNode.getIntFromDataset("maxBufferedRequests"), statsd: rootNode.getStatsdConfiguration(), diff --git a/resources/ts/embeddingParametersOf.ts b/resources/ts/embeddingParametersOf.ts new file mode 100644 index 000000000..a20c55c93 --- /dev/null +++ b/resources/ts/embeddingParametersOf.ts @@ -0,0 +1,12 @@ +import { type BalancerDesiredState } from "@intentee/paddler-client/schemas/BalancerDesiredState"; +import { type EmbeddingParameters } from "@intentee/paddler-client/schemas/EmbeddingParameters"; + +export function embeddingParametersOf( + balancerDesiredState: BalancerDesiredState, +): EmbeddingParameters { + if (!("Embeddings" in balancerDesiredState.inference_settings)) { + throw new Error("The desired state does not serve embeddings"); + } + + return balancerDesiredState.inference_settings.Embeddings; +} diff --git a/resources/ts/inferenceParametersFormKeys.ts b/resources/ts/inferenceParametersFormKeys.ts deleted file mode 100644 index 9ebc086d6..000000000 --- a/resources/ts/inferenceParametersFormKeys.ts +++ /dev/null @@ -1,17 +0,0 @@ -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/textGenerationSettingsOf.ts b/resources/ts/textGenerationSettingsOf.ts new file mode 100644 index 000000000..15e418633 --- /dev/null +++ b/resources/ts/textGenerationSettingsOf.ts @@ -0,0 +1,12 @@ +import { type BalancerDesiredState } from "@intentee/paddler-client/schemas/BalancerDesiredState"; +import { type BalancerTextGenerationSettings } from "@intentee/paddler-client/schemas/BalancerTextGenerationSettings"; + +export function textGenerationSettingsOf( + balancerDesiredState: BalancerDesiredState, +): BalancerTextGenerationSettings { + if (!("TextGeneration" in balancerDesiredState.inference_settings)) { + throw new Error("The desired state does not serve text generation"); + } + + return balancerDesiredState.inference_settings.TextGeneration; +} From 88a11828a6ea6259e22d14490c90c5918625f6b4 Mon Sep 17 00:00:00 2001 From: Mateusz Charytoniuk Date: Wed, 7 Oct 2026 16:14:29 +0200 Subject: [PATCH 002/128] Remove Windows support --- .claude/rules/paddler-cache-dir.md | 4 +- .github/workflows/build.yml | 7 +- .github/workflows/release.yml | 5 - example/README.md | 2 +- paddler_balancer/src/http_listener.rs | 1 - .../src/{cache_dir/unix.rs => cache_dir.rs} | 11 ++- paddler_cache_dir/src/cache_dir/windows.rs | 96 ------------------- paddler_cache_dir/src/cache_dir_error.rs | 4 +- .../src/cached_downloaded_model.rs | 45 ++------- paddler_cache_dir/src/lib.rs | 5 - paddler_cli_tests/Cargo.toml | 4 +- paddler_cli_tests/src/terminate_child.rs | 17 ---- paddler_cli_tests/tests/main.rs | 2 +- paddler_download_manager/src/partial_file.rs | 16 ---- ...rejects_a_directory_at_the_partial_path.rs | 2 - ..._when_a_stale_partial_cannot_be_removed.rs | 2 - ...d_when_the_cache_directory_is_read_only.rs | 2 - ...en_the_partial_file_cannot_be_truncated.rs | 2 - ...he_destination_is_a_non_empty_directory.rs | 2 - paddler_model_source/src/url_model_source.rs | 46 ++------- .../src/state_database_type.rs | 13 +-- resources/ts/components/AgentIssues.tsx | 7 +- 22 files changed, 40 insertions(+), 255 deletions(-) rename paddler_cache_dir/src/{cache_dir/unix.rs => cache_dir.rs} (90%) delete mode 100644 paddler_cache_dir/src/cache_dir/windows.rs diff --git a/.claude/rules/paddler-cache-dir.md b/.claude/rules/paddler-cache-dir.md index 780ff411a..05169597d 100644 --- a/.claude/rules/paddler-cache-dir.md +++ b/.claude/rules/paddler-cache-dir.md @@ -7,5 +7,5 @@ paths: - `paddler_cache_dir` is a root Paddler crate, it must not depend on any other Paddler crate - `paddler_cache_dir` manages Paddler's global cache directory, and all its nuances -- `paddler_cache_dir` resolves OS-related differences internally -- `paddler_cache_dir` must use cache directory patterns idiomatic to the specific operating system +- `paddler_cache_dir` resolves the cache directory from `PADDLER_CACHE_DIR`, then `$XDG_CACHE_HOME/paddler`, then `$HOME/.cache/paddler` +- Paddler supports Linux and macOS only, so the cache directory follows the XDG convention on both diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 40bac7256..0c268670e 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -22,7 +22,7 @@ jobs: runs-on: ${{ matrix.os }} strategy: matrix: - os: [macos-latest, windows-latest] + os: [macos-latest] steps: - name: checkout code @@ -32,10 +32,5 @@ jobs: - uses: Swatinem/rust-cache@v2 - - name: Install GNU Make - if: runner.os == 'Windows' - run: choco install make --version=4.4.1 --no-progress - - name: Build the cli binary run: make target/debug/paddler - shell: bash diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index de496e26c..50f8eda70 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -46,13 +46,11 @@ jobs: - name: build the cli binary run: make target/release/paddler - shell: bash env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - name: rename the cli binary run: mv ./target/release/paddler ./${{ matrix.artifact_name }} - shell: bash - name: assert cli version matches tag uses: intentee/release-action/assert-version@8125f727e97f72bfef24299d7c6d3217af1453bc @@ -94,17 +92,14 @@ jobs: libvulkan-dev libxkbcommon-dev libwayland-dev libgl-dev \ libx11-dev libxcursor-dev libxrandr-dev libxi-dev \ libxcb1-dev libfontconfig-dev pkg-config - shell: bash - name: build the gui binary run: make target/release/paddler_gui - shell: bash env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - name: rename the gui binary run: mv ./target/release/paddler_gui ./${{ matrix.artifact_name }} - shell: bash - name: assert gui version matches tag uses: intentee/release-action/assert-version@8125f727e97f72bfef24299d7c6d3217af1453bc diff --git a/example/README.md b/example/README.md index 9eec766fa..e377318f9 100644 --- a/example/README.md +++ b/example/README.md @@ -42,7 +42,7 @@ This example sets up: In production and other infrastructure deployments, Paddler runs normally in Docker with no special networking: agents connect to the balancer over the cluster network, and your applications consume the inference and OpenAI-compatible APIs directly. -The **web admin panel** is the exception. It is a browser UI served by the balancer — once the page loads, your browser calls the management server (`:8060`) and the inference server (`:8061`) directly, at the addresses the balancer was started with. For your browser to reach them, this example uses Docker **host networking** (`network_mode: host`), which binds all three servers on your host's loopback so the browser can reach them at `127.0.0.1`. **This works on Linux.** On Docker Desktop (macOS/Windows) containers run inside a Linux VM, so host networking does not expose these ports to your host browser and the panel will not be reachable. +The **web admin panel** is the exception. It is a browser UI served by the balancer — once the page loads, your browser calls the management server (`:8060`) and the inference server (`:8061`) directly, at the addresses the balancer was started with. For your browser to reach them, this example uses Docker **host networking** (`network_mode: host`), which binds all three servers on your host's loopback so the browser can reach them at `127.0.0.1`. **This works on Linux.** On Docker Desktop for macOS, containers run inside a Linux VM, so host networking does not expose these ports to your host browser and the panel will not be reachable. To use the web admin panel locally you therefore need either: diff --git a/paddler_balancer/src/http_listener.rs b/paddler_balancer/src/http_listener.rs index 76b113712..33779b483 100644 --- a/paddler_balancer/src/http_listener.rs +++ b/paddler_balancer/src/http_listener.rs @@ -18,7 +18,6 @@ impl HttpListener { pub fn bind(addr: SocketAddr) -> io::Result { let socket = Socket::new(Domain::for_address(addr), Type::STREAM, Some(Protocol::TCP))?; - #[cfg(not(windows))] socket.set_reuse_address(true)?; socket.bind(&addr.into())?; diff --git a/paddler_cache_dir/src/cache_dir/unix.rs b/paddler_cache_dir/src/cache_dir.rs similarity index 90% rename from paddler_cache_dir/src/cache_dir/unix.rs rename to paddler_cache_dir/src/cache_dir.rs index 47960b0a1..74f04cafb 100644 --- a/paddler_cache_dir/src/cache_dir/unix.rs +++ b/paddler_cache_dir/src/cache_dir.rs @@ -31,12 +31,13 @@ impl CacheDir { self.home .as_ref() .map(|home| home.join(".cache").join("paddler")) - .ok_or(CacheDirError::HomeVariableUnset { variable: "HOME" }) + .ok_or(CacheDirError::HomeVariableUnset) } } #[cfg(test)] mod tests { + use std::mem::discriminant; use std::path::PathBuf; use super::CacheDir; @@ -86,9 +87,9 @@ mod tests { xdg: None, }; - assert!(matches!( - cache.resolve(), - Err(CacheDirError::HomeVariableUnset { variable }) if variable == "HOME" - )); + assert_eq!( + cache.resolve().err().as_ref().map(discriminant), + Some(discriminant(&CacheDirError::HomeVariableUnset)) + ); } } diff --git a/paddler_cache_dir/src/cache_dir/windows.rs b/paddler_cache_dir/src/cache_dir/windows.rs deleted file mode 100644 index 79ae50085..000000000 --- a/paddler_cache_dir/src/cache_dir/windows.rs +++ /dev/null @@ -1,96 +0,0 @@ -use std::env::var_os; -use std::path::PathBuf; - -use crate::cache_dir_error::CacheDirError; - -pub struct CacheDir { - pub explicit: Option, - pub localappdata: Option, - pub userprofile: Option, -} - -impl CacheDir { - #[must_use] - pub fn from_process_env() -> Self { - Self { - explicit: var_os("PADDLER_CACHE_DIR").map(PathBuf::from), - localappdata: var_os("LOCALAPPDATA").map(PathBuf::from), - userprofile: var_os("USERPROFILE").map(PathBuf::from), - } - } - - pub fn resolve(&self) -> Result { - if let Some(explicit) = &self.explicit { - return Ok(explicit.clone()); - } - - if let Some(localappdata) = &self.localappdata { - return Ok(localappdata.join("paddler")); - } - - self.userprofile - .as_ref() - .map(|userprofile| userprofile.join("AppData").join("Local").join("paddler")) - .ok_or(CacheDirError::HomeVariableUnset { - variable: "USERPROFILE", - }) - } -} - -#[cfg(test)] -mod tests { - use std::path::PathBuf; - - use super::CacheDir; - use crate::cache_dir_error::CacheDirError; - - #[test] - fn explicit_value_wins_over_localappdata_and_userprofile() { - let cache = CacheDir { - explicit: Some(PathBuf::from(r"D:\explicit\cache")), - localappdata: Some(PathBuf::from(r"C:\Users\user\AppData\Local")), - userprofile: Some(PathBuf::from(r"C:\Users\user")), - }; - let path = cache.resolve().unwrap(); - - assert_eq!(path, PathBuf::from(r"D:\explicit\cache")); - } - - #[test] - fn localappdata_used_when_no_explicit() { - let cache = CacheDir { - explicit: None, - localappdata: Some(PathBuf::from(r"C:\Users\user\AppData\Local")), - userprofile: Some(PathBuf::from(r"C:\Users\user")), - }; - let path = cache.resolve().unwrap(); - - assert_eq!(path, PathBuf::from(r"C:\Users\user\AppData\Local\paddler")); - } - - #[test] - fn falls_back_to_userprofile_appdata_local_paddler() { - let cache = CacheDir { - explicit: None, - localappdata: None, - userprofile: Some(PathBuf::from(r"C:\Users\user")), - }; - let path = cache.resolve().unwrap(); - - assert_eq!(path, PathBuf::from(r"C:\Users\user\AppData\Local\paddler")); - } - - #[test] - fn errors_when_no_env_set() { - let cache = CacheDir { - explicit: None, - localappdata: None, - userprofile: None, - }; - - assert!(matches!( - cache.resolve(), - Err(CacheDirError::HomeVariableUnset { variable }) if variable == "USERPROFILE" - )); - } -} diff --git a/paddler_cache_dir/src/cache_dir_error.rs b/paddler_cache_dir/src/cache_dir_error.rs index 3aa447af7..46a38bc6d 100644 --- a/paddler_cache_dir/src/cache_dir_error.rs +++ b/paddler_cache_dir/src/cache_dir_error.rs @@ -2,6 +2,6 @@ use thiserror::Error; #[derive(Debug, Error)] pub enum CacheDirError { - #[error("{variable} is not set; cannot derive the paddler cache directory")] - HomeVariableUnset { variable: &'static str }, + #[error("HOME is not set; cannot derive the paddler cache directory")] + HomeVariableUnset, } diff --git a/paddler_cache_dir/src/cached_downloaded_model.rs b/paddler_cache_dir/src/cached_downloaded_model.rs index 4ab420c29..283dcb8cc 100644 --- a/paddler_cache_dir/src/cached_downloaded_model.rs +++ b/paddler_cache_dir/src/cached_downloaded_model.rs @@ -72,21 +72,10 @@ mod tests { use crate::download_lock_acquisition::DownloadLockAcquisition; fn cache_dir_at(path: &Path) -> CacheDir { - #[cfg(unix)] - { - CacheDir { - explicit: Some(path.to_path_buf()), - home: None, - xdg: None, - } - } - #[cfg(windows)] - { - CacheDir { - explicit: Some(path.to_path_buf()), - localappdata: None, - userprofile: None, - } + CacheDir { + explicit: Some(path.to_path_buf()), + home: None, + xdg: None, } } @@ -222,31 +211,17 @@ mod tests { #[test] fn new_returns_error_when_cache_dir_cannot_resolve() { - let unresolvable; - #[cfg(unix)] - { - unresolvable = CacheDir { - explicit: None, - home: None, - xdg: None, - }; - } - #[cfg(windows)] - { - unresolvable = CacheDir { - explicit: None, - localappdata: None, - userprofile: None, - }; - } + let unresolvable = CacheDir { + explicit: None, + home: None, + xdg: None, + }; let result = CachedDownloadedModel::new(&unresolvable, "https://host.example/m.gguf"); assert_eq!( result.err().as_ref().map(discriminant), - Some(discriminant(&CacheDirError::HomeVariableUnset { - variable: "HOME" - })) + Some(discriminant(&CacheDirError::HomeVariableUnset)) ); } diff --git a/paddler_cache_dir/src/lib.rs b/paddler_cache_dir/src/lib.rs index 25b84c2f7..51cb1bc6c 100644 --- a/paddler_cache_dir/src/lib.rs +++ b/paddler_cache_dir/src/lib.rs @@ -1,8 +1,3 @@ -#[cfg(unix)] -#[path = "cache_dir/unix.rs"] -pub mod cache_dir; -#[cfg(windows)] -#[path = "cache_dir/windows.rs"] pub mod cache_dir; pub mod cache_dir_error; pub mod cached_downloaded_model; diff --git a/paddler_cli_tests/Cargo.toml b/paddler_cli_tests/Cargo.toml index e923a03d7..53c33e014 100644 --- a/paddler_cli_tests/Cargo.toml +++ b/paddler_cli_tests/Cargo.toml @@ -29,6 +29,7 @@ web_admin_panel = ["paddler_cli/web_admin_panel"] anyhow = { workspace = true } async-trait = { workspace = true } log = { workspace = true } +nix = { workspace = true } paddler_balancer = { workspace = true } paddler_cli = { workspace = true } paddler_inference_parameters = { workspace = true } @@ -39,9 +40,6 @@ thiserror = { workspace = true } tokio = { workspace = true } tokio-util = { workspace = true } -[target.'cfg(unix)'.dependencies] -nix = { workspace = true } - [dev-dependencies] futures-util = { workspace = true } paddler_client = { workspace = true } diff --git a/paddler_cli_tests/src/terminate_child.rs b/paddler_cli_tests/src/terminate_child.rs index 2bfa3120c..4efe2438f 100644 --- a/paddler_cli_tests/src/terminate_child.rs +++ b/paddler_cli_tests/src/terminate_child.rs @@ -1,20 +1,14 @@ use anyhow::Context as _; use anyhow::Error; use anyhow::Result; -#[cfg(unix)] use nix::errno::Errno; -#[cfg(unix)] use nix::sys::signal::Signal; -#[cfg(unix)] use nix::sys::signal::kill; -#[cfg(unix)] use nix::unistd::Pid; use tokio::process::Child; -#[cfg(unix)] use crate::subprocess_cluster_error::SubprocessClusterError; -#[cfg(unix)] pub fn terminate_child(child: &mut Child) -> Result<()> { let Some(raw_pid) = child.id() else { return Ok(()); @@ -31,14 +25,3 @@ pub fn terminate_child(child: &mut Child) -> Result<()> { .with_context(|| format!("failed to send SIGTERM to process {raw_pid}")), } } - -#[cfg(windows)] -pub fn terminate_child(child: &mut Child) -> Result<()> { - if child.id().is_none() { - return Ok(()); - } - - child - .start_kill() - .context("failed to terminate child process") -} diff --git a/paddler_cli_tests/tests/main.rs b/paddler_cli_tests/tests/main.rs index 5d0b47da5..99522c028 100644 --- a/paddler_cli_tests/tests/main.rs +++ b/paddler_cli_tests/tests/main.rs @@ -6,7 +6,7 @@ mod balancer_distributes_token_burst_evenly_across_agents; mod balancer_emits_overflow_errors_when_embedding_burst_exceeds_max_buffered_requests; mod balancer_fans_out_embedding_batch_to_all_agents; mod balancer_registers_multiple_agents_over_time; -#[cfg(all(unix, feature = "web_admin_panel"))] +#[cfg(feature = "web_admin_panel")] mod balancer_serves_every_service_within_the_default_macos_open_file_limit; mod balancer_serves_on_the_addresses_it_announces; mod balancer_serves_typesafe_compatibility_on_the_address_it_announces; diff --git a/paddler_download_manager/src/partial_file.rs b/paddler_download_manager/src/partial_file.rs index 5d4b0f193..c1a192ea8 100644 --- a/paddler_download_manager/src/partial_file.rs +++ b/paddler_download_manager/src/partial_file.rs @@ -101,28 +101,20 @@ impl PartialFile { #[cfg(test)] mod tests { - #[cfg(unix)] use std::io::ErrorKind; - #[cfg(unix)] use std::os::unix::fs::PermissionsExt; use tempfile::TempDir; - #[cfg(unix)] use tokio::fs::create_dir; - #[cfg(unix)] use tokio::fs::create_dir_all; - #[cfg(unix)] use tokio::fs::metadata; use tokio::fs::read; - #[cfg(unix)] use tokio::fs::remove_dir_all; - #[cfg(unix)] use tokio::fs::set_permissions; use tokio::fs::try_exists; use tokio::fs::write; use tokio::io::AsyncWriteExt; - #[cfg(unix)] use crate::download_error::DownloadError; use crate::partial_file::PartialFile; @@ -222,7 +214,6 @@ mod tests { partial.remove().await.unwrap(); } - #[cfg(unix)] #[tokio::test] async fn current_size_propagates_non_notfound_error() { let directory = TempDir::new().unwrap(); @@ -239,7 +230,6 @@ mod tests { )); } - #[cfg(unix)] #[tokio::test] async fn truncate_returns_io_error_when_partial_is_a_directory() { let directory = TempDir::new().unwrap(); @@ -251,7 +241,6 @@ mod tests { assert_eq!(result.unwrap_err().kind(), ErrorKind::IsADirectory); } - #[cfg(unix)] #[tokio::test] async fn open_for_append_returns_io_error_when_partial_is_a_directory() { let directory = TempDir::new().unwrap(); @@ -263,7 +252,6 @@ mod tests { assert_eq!(result.unwrap_err().kind(), ErrorKind::IsADirectory); } - #[cfg(unix)] #[tokio::test] async fn finalize_returns_io_error_when_final_is_a_non_empty_directory() { let directory = TempDir::new().unwrap(); @@ -279,7 +267,6 @@ mod tests { assert_eq!(result.unwrap_err().kind(), ErrorKind::IsADirectory); } - #[cfg(unix)] #[tokio::test] async fn remove_propagates_non_notfound_error() { let directory = TempDir::new().unwrap(); @@ -300,7 +287,6 @@ mod tests { assert_eq!(result.unwrap_err().kind(), ErrorKind::PermissionDenied); } - #[cfg(unix)] #[tokio::test] async fn open_for_append_fails_when_parent_blocked_by_file() { let directory = TempDir::new().unwrap(); @@ -313,7 +299,6 @@ mod tests { assert_eq!(result.unwrap_err().kind(), ErrorKind::NotADirectory); } - #[cfg(unix)] #[tokio::test] async fn truncate_fails_when_parent_blocked_by_file() { let directory = TempDir::new().unwrap(); @@ -326,7 +311,6 @@ mod tests { assert_eq!(result.unwrap_err().kind(), ErrorKind::NotADirectory); } - #[cfg(unix)] #[tokio::test] async fn finalize_returns_io_error_when_parent_was_deleted_mid_download() { let directory = TempDir::new().unwrap(); diff --git a/paddler_download_manager/tests/rejects_a_directory_at_the_partial_path.rs b/paddler_download_manager/tests/rejects_a_directory_at_the_partial_path.rs index 6cadfd8b9..cd9b69f55 100644 --- a/paddler_download_manager/tests/rejects_a_directory_at_the_partial_path.rs +++ b/paddler_download_manager/tests/rejects_a_directory_at_the_partial_path.rs @@ -1,5 +1,3 @@ -#![cfg(unix)] - use tempfile::TempDir; use tokio::fs::create_dir; use tokio_util::sync::CancellationToken; diff --git a/paddler_download_manager/tests/reports_cache_permission_denied_when_a_stale_partial_cannot_be_removed.rs b/paddler_download_manager/tests/reports_cache_permission_denied_when_a_stale_partial_cannot_be_removed.rs index 970b938ea..490dfccc1 100644 --- a/paddler_download_manager/tests/reports_cache_permission_denied_when_a_stale_partial_cannot_be_removed.rs +++ b/paddler_download_manager/tests/reports_cache_permission_denied_when_a_stale_partial_cannot_be_removed.rs @@ -1,5 +1,3 @@ -#![cfg(unix)] - use std::fs::Permissions; use std::os::unix::fs::PermissionsExt as _; diff --git a/paddler_download_manager/tests/reports_cache_permission_denied_when_the_cache_directory_is_read_only.rs b/paddler_download_manager/tests/reports_cache_permission_denied_when_the_cache_directory_is_read_only.rs index 342847cea..3ec7b3749 100644 --- a/paddler_download_manager/tests/reports_cache_permission_denied_when_the_cache_directory_is_read_only.rs +++ b/paddler_download_manager/tests/reports_cache_permission_denied_when_the_cache_directory_is_read_only.rs @@ -1,5 +1,3 @@ -#![cfg(unix)] - use std::fs::Permissions; use std::io::ErrorKind; use std::os::unix::fs::PermissionsExt as _; diff --git a/paddler_download_manager/tests/reports_cache_permission_denied_when_the_partial_file_cannot_be_truncated.rs b/paddler_download_manager/tests/reports_cache_permission_denied_when_the_partial_file_cannot_be_truncated.rs index 9f70aef3c..4be003d84 100644 --- a/paddler_download_manager/tests/reports_cache_permission_denied_when_the_partial_file_cannot_be_truncated.rs +++ b/paddler_download_manager/tests/reports_cache_permission_denied_when_the_partial_file_cannot_be_truncated.rs @@ -1,5 +1,3 @@ -#![cfg(unix)] - use std::fs::Permissions; use std::os::unix::fs::PermissionsExt as _; diff --git a/paddler_download_manager/tests/reports_io_error_when_the_destination_is_a_non_empty_directory.rs b/paddler_download_manager/tests/reports_io_error_when_the_destination_is_a_non_empty_directory.rs index fd7ef20dc..4a9b67076 100644 --- a/paddler_download_manager/tests/reports_io_error_when_the_destination_is_a_non_empty_directory.rs +++ b/paddler_download_manager/tests/reports_io_error_when_the_destination_is_a_non_empty_directory.rs @@ -1,5 +1,3 @@ -#![cfg(unix)] - use std::io::ErrorKind; use tempfile::TempDir; diff --git a/paddler_model_source/src/url_model_source.rs b/paddler_model_source/src/url_model_source.rs index fdd1a5c29..17cc625d0 100644 --- a/paddler_model_source/src/url_model_source.rs +++ b/paddler_model_source/src/url_model_source.rs @@ -183,9 +183,6 @@ impl ResolvesModelSource for UrlModelSource { mod tests { use std::io; use std::mem::discriminant; - - use paddler_cache_dir::cache_dir_error::CacheDirError; - #[cfg(unix)] use std::os::unix::fs::symlink; use std::path::Path; use std::path::PathBuf; @@ -203,6 +200,7 @@ mod tests { use paddler_agent_status::slot_aggregated_status::SlotAggregatedStatus; use paddler_cache_dir::cache_dir::CacheDir; + use paddler_cache_dir::cache_dir_error::CacheDirError; use paddler_cache_dir::cached_downloaded_model::CachedDownloadedModel; use paddler_download_manager::download_error::DownloadError; use paddler_local_http_fixture::fixture_response::FixtureResponse; @@ -230,21 +228,10 @@ mod tests { } fn cache_dir_at(path: &Path) -> CacheDir { - #[cfg(unix)] - { - CacheDir { - explicit: Some(path.to_path_buf()), - home: None, - xdg: None, - } - } - #[cfg(windows)] - { - CacheDir { - explicit: Some(path.to_path_buf()), - localappdata: None, - userprofile: None, - } + CacheDir { + explicit: Some(path.to_path_buf()), + home: None, + xdg: None, } } @@ -350,7 +337,6 @@ mod tests { ); } - #[cfg(unix)] #[tokio::test] async fn cache_subdir_creation_failure_registers_model_cache_is_corrupted() { let directory = TempDir::new().unwrap(); @@ -376,7 +362,6 @@ mod tests { ); } - #[cfg(unix)] #[tokio::test] async fn lock_open_io_error_registers_model_cache_is_corrupted() { let directory = TempDir::new().unwrap(); @@ -603,21 +588,10 @@ mod tests { } fn unresolvable_cache_dir() -> CacheDir { - #[cfg(unix)] - { - CacheDir { - explicit: None, - home: None, - xdg: None, - } - } - #[cfg(windows)] - { - CacheDir { - explicit: None, - localappdata: None, - userprofile: None, - } + CacheDir { + explicit: None, + home: None, + xdg: None, } } @@ -636,7 +610,7 @@ mod tests { assert_eq!( result.err().as_ref().map(discriminant), Some(discriminant(&ModelSourceError::CacheDirectoryUnresolvable( - CacheDirError::HomeVariableUnset { variable: "HOME" } + CacheDirError::HomeVariableUnset ))) ); } diff --git a/paddler_state_database/src/state_database_type.rs b/paddler_state_database/src/state_database_type.rs index 72f8fd0eb..258ddeba8 100644 --- a/paddler_state_database/src/state_database_type.rs +++ b/paddler_state_database/src/state_database_type.rs @@ -121,19 +121,10 @@ mod tests { #[test] fn parses_a_file_url_with_an_absolute_path() { - #[cfg(unix)] - let expected_path = "/absolute/path"; - #[cfg(unix)] - let url = "file:///absolute/path"; - #[cfg(windows)] - let expected_path = "C:/absolute/path"; - #[cfg(windows)] - let url = "file://C:/absolute/path"; - - let result = StateDatabaseType::from_str(url).unwrap(); + let result = StateDatabaseType::from_str("file:///absolute/path").unwrap(); assert!( - matches!(&result, StateDatabaseType::File(path) if path == &PathBuf::from(expected_path)) + matches!(&result, StateDatabaseType::File(path) if path == &PathBuf::from("/absolute/path")) ); } diff --git a/resources/ts/components/AgentIssues.tsx b/resources/ts/components/AgentIssues.tsx index 9dcfdbbcd..4c449fee1 100644 --- a/resources/ts/components/AgentIssues.tsx +++ b/resources/ts/components/AgentIssues.tsx @@ -417,9 +417,10 @@ export function AgentIssues({ issues }: { issues: Array }) { What can you do?{" "}

    Grant write permission to the cache directory ( - $XDG_CACHE_HOME/paddler on Linux/macOS,{" "} - %LOCALAPPDATA%\paddler on Windows), or set{" "} - PADDLER_CACHE_DIR to a writable location. + $XDG_CACHE_HOME/paddler, or{" "} + ~/.cache/paddler when XDG_CACHE_HOME{" "} + is unset), or set PADDLER_CACHE_DIR to a writable + location.

  • ); From 2a6eb0cd963ba76a91776805a8853711b97207f9 Mon Sep 17 00:00:00 2001 From: Mateusz Charytoniuk Date: Wed, 7 Oct 2026 16:14:29 +0200 Subject: [PATCH 003/128] Run tests one at a time on GPU builds so suites never compete for the device --- .cargo/config.toml | 2 ++ .claude/skills/running-all-tests/SKILL.md | 2 +- .claude/skills/running-coverage/SKILL.md | 2 +- .config/nextest.toml | 4 ++++ Makefile | 12 ++++++++---- paddler_client_javascript/package.json | 2 +- 6 files changed, 17 insertions(+), 7 deletions(-) create mode 100644 .cargo/config.toml diff --git a/.cargo/config.toml b/.cargo/config.toml new file mode 100644 index 000000000..8af59dd8c --- /dev/null +++ b/.cargo/config.toml @@ -0,0 +1,2 @@ +[env] +RUST_TEST_THREADS = "1" diff --git a/.claude/skills/running-all-tests/SKILL.md b/.claude/skills/running-all-tests/SKILL.md index 791bcf0f1..d4fe81431 100644 --- a/.claude/skills/running-all-tests/SKILL.md +++ b/.claude/skills/running-all-tests/SKILL.md @@ -56,7 +56,7 @@ On NixOS the pinned `ruff` wheel is dynamically linked, so suite 3 needs `nix-ld ## Step 3: rules during the run -- **Serialize GPU suites.** When `$DEVICE` is `cuda` or `metal`, run test suites sequentially to avoid device contention. +- **Serialize GPU suites.** When `$DEVICE` is `cuda` or `metal`, run test suites sequentially to avoid device contention. The repository enforces one GPU test at a time: the Makefile is `.NOTPARALLEL`, nextest's default profile runs a single test (only `TEST_DEVICE=cpu` selects the parallel `cpu` profile), the JS suites run with `--test-concurrency=1`, and `.cargo/config.toml` sets `RUST_TEST_THREADS=1`. Never override that with `NEXTEST_PROFILE=cpu`, `--test-threads`, or `make -j` on a GPU build. - **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 diff --git a/.claude/skills/running-coverage/SKILL.md b/.claude/skills/running-coverage/SKILL.md index bd55020ae..a25aece6f 100644 --- a/.claude/skills/running-coverage/SKILL.md +++ b/.claude/skills/running-coverage/SKILL.md @@ -42,7 +42,7 @@ Copy this checklist and tick each item as the suite completes: ## Step 3: rules during the run -- **Serialize GPU suites.** When `$DEVICE` is `cuda` or `metal`, run test suites sequentially to avoid device contention. +- **Serialize GPU suites.** When `$DEVICE` is `cuda` or `metal`, run test suites sequentially to avoid device contention. The repository enforces one GPU test at a time: the Makefile is `.NOTPARALLEL`, nextest's default profile runs a single test (only `TEST_DEVICE=cpu` selects the parallel `cpu` profile), the JS suites run with `--test-concurrency=1`, and `.cargo/config.toml` sets `RUST_TEST_THREADS=1`. Never override that with `NEXTEST_PROFILE=cpu`, `--test-threads`, or `make -j` on a GPU build. - **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 diff --git a/.config/nextest.toml b/.config/nextest.toml index 731e90d13..f3b420207 100644 --- a/.config/nextest.toml +++ b/.config/nextest.toml @@ -3,6 +3,10 @@ serial = { max-threads = 1 } [profile.default] slow-timeout = { period = "10s", terminate-after = 3 } +test-threads = 1 + +[profile.cpu] +test-threads = "num-cpus" [[profile.default.overrides]] filter = '(package(=paddler_tests) | package(=paddler_cli_tests)) and kind(test)' diff --git a/Makefile b/Makefile index e892386aa..c5b0b7a3e 100644 --- a/Makefile +++ b/Makefile @@ -1,5 +1,7 @@ .DEFAULT_GOAL := target/release/paddler +.NOTPARALLEL: + RUST_LOG ?= debug PADDLER_CRATES := paddler_agent paddler_agent_decision paddler_agent_embeddings paddler_agent_pointer_head paddler_agent_runner paddler_agent_runtime paddler_agent_status paddler_agent_text_generation paddler_balancer paddler_balancer_runner paddler_cache_dir paddler_cli paddler_client paddler_download_manager paddler_gui paddler_image_decoder paddler_inference_parameters paddler_messaging paddler_model_source paddler_openai_translation paddler_request_registry paddler_service_thread paddler_state_database paddler_tool_call_validator paddler_typesafe_translation @@ -15,11 +17,13 @@ LLVM_COV_THIRD_PARTY_SOURCES := /\.cargo/(registry|git)/|/\.rustup/toolchains/|^ TEST_DEVICE ?= cpu ifeq ($(TEST_DEVICE),cpu) +NEXTEST_PROFILE := cpu PADDLER_TEST_BINARY := target/debug/paddler RUN_WITH_TEST_CLUSTER_BINARY := target/debug/run_with_test_cluster TEST_DEVICE_FEATURE_SUFFIX := TEST_DEVICE_TARGET_DIR := else +NEXTEST_PROFILE := default PADDLER_TEST_BINARY := target/$(TEST_DEVICE)/debug/paddler RUN_WITH_TEST_CLUSTER_BINARY := target/$(TEST_DEVICE)/debug/run_with_test_cluster TEST_DEVICE_FEATURE_SUFFIX := ,$(TEST_DEVICE) @@ -188,7 +192,7 @@ test.client.js.llm: $(PADDLER_TEST_BINARY) node_modules target/test-model-cards. .PHONY: test.coverage test.coverage: esbuild-meta.json node_modules target/kev/model.gguf target/kev/pointer_head.gguf target/test-model-cards.stamp cargo llvm-cov clean --workspace - cargo llvm-cov nextest --features tests_that_use_llms,web_admin_panel$(TEST_DEVICE_FEATURE_SUFFIX) --no-report --workspace + NEXTEST_PROFILE=$(NEXTEST_PROFILE) cargo llvm-cov nextest --features tests_that_use_llms,web_admin_panel$(TEST_DEVICE_FEATURE_SUFFIX) --no-report --workspace cargo llvm-cov report --no-default-ignore-filename-regex --ignore-filename-regex '$(LLVM_COV_THIRD_PARTY_SOURCES)' --json --output-path target/llvm-cov.json cargo llvm-cov report --no-default-ignore-filename-regex --ignore-filename-regex '$(LLVM_COV_THIRD_PARTY_SOURCES)' --lcov --output-path target/lcov.info cargo llvm-cov report --no-default-ignore-filename-regex --ignore-filename-regex '$(LLVM_COV_THIRD_PARTY_SOURCES)' @@ -235,11 +239,11 @@ test.coverage-clean: .PHONY: test.integration test.integration: target/kev/model.gguf target/kev/pointer_head.gguf target/test-model-cards.stamp - cargo nextest run -p paddler_tests -p paddler_cli_tests --features tests_that_use_llms$(TEST_DEVICE_FEATURE_SUFFIX) $(TEST_DEVICE_TARGET_DIR) + NEXTEST_PROFILE=$(NEXTEST_PROFILE) cargo nextest run -p paddler_tests -p paddler_cli_tests --features tests_that_use_llms$(TEST_DEVICE_FEATURE_SUFFIX) $(TEST_DEVICE_TARGET_DIR) .PHONY: test.integration.opencode test.integration.opencode: target/test-model-cards.stamp - cargo nextest run -p paddler_opencode_tests --features tests_that_use_llms,tests_that_use_opencode$(TEST_DEVICE_FEATURE_SUFFIX) $(TEST_DEVICE_TARGET_DIR) + NEXTEST_PROFILE=$(NEXTEST_PROFILE) cargo nextest run -p paddler_opencode_tests --features tests_that_use_llms,tests_that_use_opencode$(TEST_DEVICE_FEATURE_SUFFIX) $(TEST_DEVICE_TARGET_DIR) .PHONY: test.kev_converter.python.llm test.kev_converter.python.llm: paddler_kev_converter_python/.venv @@ -255,7 +259,7 @@ test.typesafe.python.llm: $(RUN_WITH_TEST_CLUSTER_BINARY) paddler_typesafe_clien .PHONY: test.unit test.unit: esbuild-meta.json - cargo nextest run --features web_admin_panel$(TEST_DEVICE_FEATURE_SUFFIX) $(TEST_DEVICE_TARGET_DIR) + NEXTEST_PROFILE=$(NEXTEST_PROFILE) cargo nextest run --features web_admin_panel$(TEST_DEVICE_FEATURE_SUFFIX) $(TEST_DEVICE_TARGET_DIR) .PHONY: watch watch: node_modules diff --git a/paddler_client_javascript/package.json b/paddler_client_javascript/package.json index 176ffccca..eeaa90456 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": "tsc -p tsconfig.test.json && node --experimental-eventsource --enable-source-maps --import tsx --test 'tests/unit/**/*.test.ts' 'tests/cluster/**/*.test.ts'", + "test": "tsc -p tsconfig.test.json && node --experimental-eventsource --enable-source-maps --import tsx --test --test-concurrency=1 'tests/unit/**/*.test.ts' 'tests/cluster/**/*.test.ts'", "test:coverage": "tsc -p tsconfig.test.json && node --experimental-eventsource --enable-source-maps --import tsx --test --test-concurrency=1 --experimental-test-coverage --test-coverage-exclude='**/node_modules/**' --test-coverage-branches=100 --test-coverage-functions=100 --test-coverage-lines=100 'tests/**/*.test.ts'", "test:llm": "tsc -p tsconfig.test.json && node --experimental-eventsource --enable-source-maps --import tsx --test --test-concurrency=1 'tests/llm/**/*.test.ts'", "prepack": "tsc -p ." From b5a4fc8ea1190b7e9228219f31048e312e12f817 Mon Sep 17 00:00:00 2001 From: Mateusz Charytoniuk Date: Thu, 8 Oct 2026 03:31:39 +0200 Subject: [PATCH 004/128] Use the published llama-cpp-bindings 0.17 --- Cargo.lock | 36 +++++++++++++++++++++++++++--------- Cargo.toml | 6 +++--- 2 files changed, 30 insertions(+), 12 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 4f275a254..590a35a6f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3760,7 +3760,9 @@ checksum = "11d3d7f243d5c5a8b9bb5d6dd2b1602c0cb0b9db1621bafc7ed66e35ff9fe092" [[package]] name = "llama-cpp-bindings" -version = "0.16.0" +version = "0.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4d32b1543ed34188ce067bd879dbda34751b2a87f16334be166a68d8d68308ee" dependencies = [ "encoding_rs", "enumflags2", @@ -3782,7 +3784,9 @@ dependencies = [ [[package]] name = "llama-cpp-bindings-build" -version = "0.16.0" +version = "0.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "900445539f2422ee3be60aad7a981c888e7507be72d167aa63f660c8d8e99a5f" dependencies = [ "bindgen", "cc", @@ -3794,14 +3798,18 @@ dependencies = [ [[package]] name = "llama-cpp-bindings-sys" -version = "0.16.0" +version = "0.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a611e9d639cf6cc4ae74b24970d1037f1e22c04c01557cff27a2866b28985306" dependencies = [ "llama-cpp-bindings-build", ] [[package]] name = "llama-cpp-bindings-types" -version = "0.16.0" +version = "0.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "239fb64cb0059fc87e383b5c2e994a574c8d3dd1a175968030a93326e74c7614" dependencies = [ "serde", "serde_json", @@ -3810,14 +3818,18 @@ dependencies = [ [[package]] name = "llama-cpp-error-recorder" -version = "0.16.0" +version = "0.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f160564c9609b88c87d2441998f9656c5e5b7eb115714bfb6b53c1cff8a1c5f" dependencies = [ "log", ] [[package]] name = "llama-cpp-ffi-status" -version = "0.16.0" +version = "0.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "62c339f169e2343482711b34860874b24b00e99b4a10536cd40fce4ea940278c" dependencies = [ "llama-cpp-bindings-sys", "thiserror 2.0.18", @@ -3825,7 +3837,9 @@ dependencies = [ [[package]] name = "llama-cpp-gbnf" -version = "0.16.0" +version = "0.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2dd18cc23df7007df309f8501d6e7011ead145c68ecd614a2f6f96deaed6454" dependencies = [ "llama-cpp-bindings-sys", "llama-cpp-ffi-status", @@ -3834,11 +3848,15 @@ dependencies = [ [[package]] name = "llama-cpp-log-decoder" -version = "0.16.0" +version = "0.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3cdaeb8832f911ff3ee45e300f7c170b2df6df04ea6035a6d60a3b98975a513d" [[package]] name = "llama-cpp-wrapper-sources" -version = "0.16.0" +version = "0.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47bc79989894fc2badfe8aa09545b7b51e6ffb061345bb06928ac4a2c32b88d0" dependencies = [ "serde", "serde_json", diff --git a/Cargo.toml b/Cargo.toml index 982d98fd8..e10f6c107 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -74,9 +74,9 @@ icu_normalizer = "2.2" image = "0.25" indoc = "2.0" 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.17" +llama-cpp-bindings-sys = "0.17" +llama-cpp-bindings-types = "0.17" base64 = "0.22" log = "0.4" mime_guess = "2.0" From 3ab5bdaa2d96654e4adb45806c2b543e4bf9bb2d Mon Sep 17 00:00:00 2001 From: Mateusz Charytoniuk Date: Thu, 8 Oct 2026 05:28:40 +0200 Subject: [PATCH 005/128] Keep the inference mode in the balancer desired state and switch it at runtime --- .claude/rules/paddler-balancer.md | 10 +- .claude/rules/paddler-test-cluster-harness.md | 1 + README.md | 18 +- flake.nix | 32 +-- jarmuz/worker-paddler.mjs | 3 +- .../src/management_socket_client_service.rs | 5 +- paddler_agent/src/reconciliation_service.rs | 5 +- paddler_balancer/src/agent_controller_pool.rs | 42 ++-- .../src/balancer_applicable_state_holder.rs | 58 +++++- .../src/buffered_request_manager.rs | 3 +- .../cluster_serves_another_inference_mode.rs | 44 +++++ .../agent_result_stream_transformer.rs | 25 +++ .../http_route/post_chat_completions.rs | 18 +- .../http_route/post_responses.rs | 27 ++- .../openai_service/openai_failure.rs | 21 +- .../typesafe_service/http_route/get_models.rs | 30 ++- .../http_route/post_system_one.rs | 9 + .../inference_mode_refusal_http_response.rs | 15 ++ .../src/compatibility/typesafe_service/mod.rs | 1 + ...post_continue_from_conversation_history.rs | 7 + .../api/post_continue_from_raw_prompt.rs | 7 + .../http_route/api/post_decide.rs | 7 + .../api/post_generate_embedding_batch.rs | 33 ++-- .../inference_socket_controller_context.rs | 2 + .../http_route/api/ws_inference_socket/mod.rs | 54 +++++- .../spawn_cluster_inference_mode_watcher.rs | 53 +++++ .../inference_mode_routes.rs | 21 -- paddler_balancer/src/inference_service/mod.rs | 19 +- paddler_balancer/src/lib.rs | 1 + .../api/get_balancer_applicable_state.rs | 23 +-- .../api/get_balancer_desired_state.rs | 25 +-- .../http_route/api/get_buffered_requests.rs | 19 +- .../api/put_balancer_desired_state.rs | 82 ++------ .../src/reconciliation_service.rs | 14 +- paddler_balancer/src/snapshots_stream.rs | 3 +- .../src/unbounded_stream_from_agent.rs | 3 +- .../src/web_admin_panel_service/mod.rs | 2 - .../web_admin_panel_service/template_data.rs | 52 ----- .../templates/web_admin_panel.html | 1 - ..._closed_after_dispatch_as_a_bad_gateway.rs | 6 +- .../src/balancer_runner_config.rs | 6 +- .../src/balancer_service_bundle.rs | 71 ++----- .../src/balancer_serving_mode.rs | 44 ----- paddler_balancer_runner/src/lib.rs | 1 - ...fails_to_start_when_an_address_is_taken.rs | 17 +- ...r_preserves_the_persisted_desired_state.rs | 32 ++- ..._refuses_a_stored_state_of_another_mode.rs | 57 ------ ...serves_the_desired_state_it_starts_with.rs | 9 +- ...anel_omits_the_services_it_does_not_run.rs | 3 - ..._panel_shows_the_balancer_configuration.rs | 14 +- ...hows_the_typesafe_compatibility_address.rs | 36 ---- .../tests/ephemeral_balancer_runner_params.rs | 8 +- paddler_balancer_runner/tests/main.rs | 3 - paddler_cli/src/cmd/balancer.rs | 183 ++++++++++++++++-- paddler_cli/src/cmd/balancer_decision.rs | 38 ---- paddler_cli/src/cmd/balancer_embeddings.rs | 24 --- .../src/cmd/balancer_shared_arguments.rs | 163 ---------------- .../src/cmd/balancer_text_generation.rs | 38 ---- paddler_cli/src/cmd/mod.rs | 4 - paddler_cli/src/lib.rs | 7 +- paddler_cli/tests/main.rs | 4 +- ...cts_more_slots_than_llama_cpp_can_serve.rs | 2 +- ...e_when_its_management_address_is_taken.rs} | 3 +- ...uses_compatibility_flags_of_other_modes.rs | 28 --- ...re_when_its_management_address_is_taken.rs | 26 --- .../src/start_subprocess_cluster.rs | 23 +-- ...tes_buffered_requests_across_two_agents.rs | 25 +-- ...ithin_the_default_macos_open_file_limit.rs | 8 +- ...er_serves_on_the_addresses_it_announces.rs | 9 +- ...mpatibility_on_the_address_it_announces.rs | 36 ---- ...el_shows_its_command_line_configuration.rs | 8 +- ..._flags_serves_no_compatibility_services.rs | 36 ++++ paddler_cli_tests/tests/main.rs | 2 +- paddler_client/src/client_inference.rs | 7 + .../src/inference_socket/connection.rs | 23 ++- .../inbound_message_router.rs | 49 +++++ paddler_client/src/inference_socket/pool.rs | 13 +- .../src/inference_socket/spawn_read_task.rs | 9 +- .../src/inferenceSocketClient.ts | 23 +++ .../src/schemas/BalancerDesiredState.ts | 10 +- .../src/schemas/BalancerInferenceSettings.ts | 15 -- .../src/schemas/InferenceNotification.ts | 17 ++ .../DesiredStateServesAnotherModeError.ts | 6 - .../tests/SpawnedBalancer.ts | 20 +- ...dsTheDesiredStateTheBalancerStores.test.ts | 16 +- ...ientReportsTheClusterInferenceMode.test.ts | 63 ++++++ ...ancerReportsAnExitBeforeAnnouncing.test.ts | 1 - ...sTheDesiredStateOfItsInferenceMode.test.ts | 29 --- ...ortsAProcessThatIgnoresTermination.test.ts | 1 - ...iversADecisionTimeoutWithoutAgents.test.ts | 17 +- ...sRefusesADesiredStateOfAnotherMode.test.ts | 30 --- .../tests/qwen3DesiredState.ts | 34 ++-- .../tests/withSpawnedBalancer.ts | 4 - .../tests/withTextGenerationSettings.ts | 24 --- paddler_gui/src/model_preset.rs | 41 ++-- paddler_gui/src/running_balancer_data.rs | 7 +- paddler_gui/src/running_balancer_snapshot.rs | 7 +- paddler_gui/src/start_balancer_form_data.rs | 42 ++-- ...le_running_shows_why_on_the_home_screen.rs | 7 +- .../a_running_cluster_lists_its_agents.rs | 6 +- ...shows_its_configured_and_applied_models.rs | 5 +- ...g_cluster_without_agents_waits_for_them.rs | 7 +- paddler_messaging/src/agent_desired_state.rs | 82 +++++++- .../src/agent_inference_settings.rs | 85 -------- paddler_messaging/src/agent_runtime_status.rs | 19 -- .../src/agent_text_generation_settings.rs | 42 ++++ .../src/balancer_desired_state.rs | 22 +-- .../src/balancer_inference_settings.rs | 58 ------ .../src/inference_client/message.rs | 2 + paddler_messaging/src/inference_client/mod.rs | 1 + .../src/inference_client/notification.rs | 10 + paddler_messaging/src/inference_mode.rs | 3 +- paddler_messaging/src/lib.rs | 1 - .../generate_embedding_batch_params/mod.rs | 56 +++--- .../src/openai_translation_error.rs | 4 - .../src/responses_request.rs | 29 +-- ...slated_before_the_unix_epoch_is_refused.rs | 8 +- ...er_of_another_inference_mode_is_refused.rs | 26 --- paddler_openai_translation/tests/main.rs | 1 - ...eport_the_sampling_parameters_in_effect.rs | 18 +- ...ure_requested_state_serves_cluster_mode.rs | 54 ------ ...ensure_stored_state_serves_cluster_mode.rs | 57 ------ paddler_state_database/src/file.rs | 148 +++++--------- paddler_state_database/src/lib.rs | 2 - paddler_state_database/src/memory.rs | 53 +---- paddler_state_database/src/schema.rs | 13 +- .../src/state_database_error.rs | 16 -- .../src/state_database_schema_version.rs | 3 +- .../src/state_database_type.rs | 18 +- paddler_test_cluster_harness/src/cluster.rs | 37 +++- .../src/cluster_desired_state.rs | 15 +- .../src/cluster_params.rs | 7 +- .../src/collect_embedding_results.rs | 41 +++- .../src/collect_generated_tokens.rs | 39 +++- .../src/model_card/mod.rs | 47 ++--- .../src/raw_inference_socket.rs | 17 +- ...s.rs => cluster_without_agents_serving.rs} | 8 +- ...sired_state_with_chat_template_override.rs | 29 +-- .../desired_state_with_halved_image_resize.rs | 43 ++-- ...esired_state_with_multimodal_projection.rs | 31 +-- paddler_tests/src/kev_0_8b_desired_state.rs | 8 +- paddler_tests/src/lib.rs | 2 +- ...sired_state_with_chat_template_override.rs | 5 +- paddler_tests/src/start_cluster.rs | 23 +-- ...es_http_token_generation_as_unavailable.rs | 71 +++++++ ...ration_refuses_decisions_as_unavailable.rs | 45 +++++ ...fuses_embedding_batches_as_unavailable.rs} | 19 +- ...rence_when_the_desired_model_is_missing.rs | 3 +- ...hat_template_error_when_rendering_fails.rs | 3 +- ...ompt_exceeding_a_non_causal_micro_batch.rs | 3 +- ...s_slot_cannot_start_for_excessive_slots.rs | 3 +- ...being_prepared_when_state_change_begins.rs | 3 +- ...tate_change_waits_for_in_flight_request.rs | 3 +- ...tate_change_waits_for_in_flight_request.rs | 3 +- ...mode_the_cluster_switches_to_at_runtime.rs | 73 +++++++ ...uster_serves_no_token_generation_routes.rs | 96 --------- ...etes_buffered_request_after_agent_joins.rs | 23 +-- ...ce_socket_recovers_from_embeddings_mode.rs | 119 ++++++++++++ ...r_memory_storage_persists_desired_state.rs | 3 +- ...s_chat_template_override_across_restart.rs | 8 +- ...r_persists_desired_state_across_restart.rs | 5 +- ...sts_huggingface_mmproj_in_desired_state.rs | 6 +- ...ists_local_mmproj_path_in_desired_state.rs | 6 +- ...lancer_persists_model_switch_in_storage.rs | 5 +- ...cer_persists_url_model_in_desired_state.rs | 3 +- ...sired_state_with_an_ambiguous_parameter.rs | 12 +- ...egistration_under_a_registered_agent_id.rs | 6 +- ...ate_from_a_socket_that_did_not_register.rs | 6 +- ...late_does_not_compile_for_invalid_jinja.rs | 15 +- ...compile_recovers_when_template_replaced.rs | 20 +- ...r_reports_download_server_denied_access.rs | 3 +- ...alancer_reports_download_server_errored.rs | 3 +- ..._reports_download_server_is_unreachable.rs | 3 +- ...ancer_reports_download_url_is_malformed.rs | 3 +- ...eports_huggingface_model_does_not_exist.rs | 3 +- ...model_cannot_be_loaded_for_corrupt_file.rs | 3 +- ...model_cannot_be_loaded_for_invalid_gguf.rs | 24 +-- ...cer_reports_model_does_not_exist_at_url.rs | 3 +- ...ancer_reports_model_file_does_not_exist.rs | 3 +- ...ction_cannot_be_loaded_for_invalid_file.rs | 15 +- ...ction_cannot_be_loaded_for_missing_file.rs | 15 +- ..._find_chat_template_for_embedding_model.rs | 3 +- ...rns_503_when_request_buffering_disabled.rs | 12 +- ...504_when_inference_item_timeout_is_zero.rs | 12 +- ...alancer_serves_inference_over_websocket.rs | 54 +++--- ...est_after_agent_with_capacity_registers.rs | 32 +-- ..._drains_in_flight_inference_before_swap.rs | 3 +- ...ate_override_applied_to_embedding_model.rs | 3 +- ...emplate_override_replaces_model_builtin.rs | 6 +- ..._template_swaps_between_inference_calls.rs | 3 +- ...e_for_an_embedding_batch_without_agents.rs | 51 ----- ...gent_that_reports_issues_while_starting.rs | 3 +- ..._questions_is_rejected_with_bad_request.rs | 14 +- ...ch_client_releases_the_buffered_request.rs | 7 +- ..._mode_mismatch_while_serving_embeddings.rs | 63 ++++++ ...s_requests_after_the_balancer_closes_it.rs | 3 + ...when_the_cluster_inference_mode_changes.rs | 104 ++++++++++ paddler_tests/tests/main.rs | 17 +- ...rns_balancer_desired_state_that_was_put.rs | 16 +- ...e_applicable_state_of_the_desired_state.rs | 5 +- ...e_cluster_serves_another_inference_mode.rs | 63 ++++++ ...alancer_failures_with_their_http_status.rs | 15 +- ...e_cluster_serves_another_inference_mode.rs | 60 ++++++ ...ports_a_socket_closed_before_any_answer.rs | 28 +++ ...luster_exports_the_url_of_every_service.rs | 22 +++ ...es_only_the_services_its_cluster_serves.rs | 22 --- .../tests/typesafe_health_returns_ok.rs | 5 +- ...e_cluster_serves_another_inference_mode.rs | 29 +++ ...dels_lists_the_model_the_cluster_serves.rs | 7 +- ...browsers_send_and_read_typesafe_headers.rs | 5 +- ...em_one_answers_questions_in_their_order.rs | 4 +- ...e_cluster_serves_another_inference_mode.rs | 34 ++++ ...cts_malformed_requests_as_unprocessable.rs | 5 +- ...alancer_failures_with_their_http_status.rs | 6 +- resources/ts/components/ChangeModelForm.tsx | 85 ++++---- resources/ts/components/ChangeModelPage.tsx | 43 ++-- .../ChatTemplateContextProvider.tsx | 12 +- .../ts/components/ConversationPromptInput.tsx | 7 +- .../ConversationPromptInputImageButton.tsx | 6 + .../ConversationPromptInputThinkingToggle.tsx | 5 + .../components/EmbeddingParametersFields.tsx | 11 +- resources/ts/components/Home.tsx | 25 +-- resources/ts/components/PromptPage.tsx | 99 ++++++---- .../TextGenerationSettingsFields.tsx | 27 ++- ...TokenGenerationDisabledContextProvider.tsx | 57 ++++++ .../TokenGenerationDisabledNotice.module.css | 8 + .../TokenGenerationDisabledNotice.tsx | 29 +++ resources/ts/components/WorkbenchLayout.tsx | 14 +- .../contexts/PaddlerConfigurationContext.ts | 5 - .../TokenGenerationDisabledContext.ts | 12 ++ resources/ts/controller_dashboard.tsx | 4 - resources/ts/embeddingParametersOf.ts | 12 -- resources/ts/textGenerationSettingsOf.ts | 12 -- 233 files changed, 2679 insertions(+), 2635 deletions(-) create mode 100644 paddler_balancer/src/cluster_serves_another_inference_mode.rs create mode 100644 paddler_balancer/src/compatibility/typesafe_service/inference_mode_refusal_http_response.rs create mode 100644 paddler_balancer/src/inference_service/http_route/api/ws_inference_socket/spawn_cluster_inference_mode_watcher.rs delete mode 100644 paddler_balancer/src/inference_service/inference_mode_routes.rs delete mode 100644 paddler_balancer_runner/src/balancer_serving_mode.rs delete mode 100644 paddler_balancer_runner/tests/balancer_runner_refuses_a_stored_state_of_another_mode.rs delete mode 100644 paddler_balancer_runner/tests/balancer_web_admin_panel_shows_the_typesafe_compatibility_address.rs delete mode 100644 paddler_cli/src/cmd/balancer_decision.rs delete mode 100644 paddler_cli/src/cmd/balancer_embeddings.rs delete mode 100644 paddler_cli/src/cmd/balancer_shared_arguments.rs delete mode 100644 paddler_cli/src/cmd/balancer_text_generation.rs rename paddler_cli/tests/{paddler_balancer_decision_exits_with_a_failure_when_its_management_address_is_taken.rs => paddler_balancer_exits_with_a_failure_when_its_management_address_is_taken.rs} (87%) delete mode 100644 paddler_cli/tests/paddler_balancer_refuses_compatibility_flags_of_other_modes.rs delete mode 100644 paddler_cli/tests/paddler_balancer_text_generation_exits_with_a_failure_when_its_management_address_is_taken.rs delete mode 100644 paddler_cli_tests/tests/balancer_serves_typesafe_compatibility_on_the_address_it_announces.rs create mode 100644 paddler_cli_tests/tests/balancer_without_compatibility_flags_serves_no_compatibility_services.rs delete mode 100644 paddler_client_javascript/src/schemas/BalancerInferenceSettings.ts create mode 100644 paddler_client_javascript/src/schemas/InferenceNotification.ts delete mode 100644 paddler_client_javascript/tests/DesiredStateServesAnotherModeError.ts create mode 100644 paddler_client_javascript/tests/cluster/inferenceSocketClientReportsTheClusterInferenceMode.test.ts delete mode 100644 paddler_client_javascript/tests/cluster/spawnedBalancerStoresTheDesiredStateOfItsInferenceMode.test.ts delete mode 100644 paddler_client_javascript/tests/cluster/withTextGenerationSettingsRefusesADesiredStateOfAnotherMode.test.ts delete mode 100644 paddler_client_javascript/tests/withTextGenerationSettings.ts delete mode 100644 paddler_messaging/src/balancer_inference_settings.rs create mode 100644 paddler_messaging/src/inference_client/notification.rs delete mode 100644 paddler_openai_translation/tests/a_responses_request_to_a_cluster_of_another_inference_mode_is_refused.rs delete mode 100644 paddler_state_database/src/ensure_requested_state_serves_cluster_mode.rs delete mode 100644 paddler_state_database/src/ensure_stored_state_serves_cluster_mode.rs rename paddler_tests/src/{decision_cluster_without_agents_params.rs => cluster_without_agents_serving.rs} (50%) create mode 100644 paddler_tests/tests/a_cluster_serving_embeddings_refuses_http_token_generation_as_unavailable.rs create mode 100644 paddler_tests/tests/a_cluster_serving_text_generation_refuses_decisions_as_unavailable.rs rename paddler_tests/tests/{a_text_generation_cluster_serves_no_embedding_route.rs => a_cluster_serving_text_generation_refuses_embedding_batches_as_unavailable.rs} (68%) create mode 100644 paddler_tests/tests/agents_serve_the_inference_mode_the_cluster_switches_to_at_runtime.rs delete mode 100644 paddler_tests/tests/an_embeddings_cluster_serves_no_token_generation_routes.rs create mode 100644 paddler_tests/tests/balancer_inference_socket_recovers_from_embeddings_mode.rs delete mode 100644 paddler_tests/tests/client_reports_service_unavailable_for_an_embedding_batch_without_agents.rs create mode 100644 paddler_tests/tests/inference_socket_answers_a_conversation_history_request_with_an_inference_mode_mismatch_while_serving_embeddings.rs create mode 100644 paddler_tests/tests/inference_socket_notifies_clients_only_when_the_cluster_inference_mode_changes.rs create mode 100644 paddler_tests/tests/openai_chat_completion_is_unavailable_while_the_cluster_serves_another_inference_mode.rs create mode 100644 paddler_tests/tests/openai_responses_is_unavailable_while_the_cluster_serves_another_inference_mode.rs create mode 100644 paddler_tests/tests/raw_inference_socket_reports_a_socket_closed_before_any_answer.rs create mode 100644 paddler_tests/tests/run_with_test_cluster_exports_the_url_of_every_service.rs delete mode 100644 paddler_tests/tests/run_with_test_cluster_exposes_only_the_services_its_cluster_serves.rs create mode 100644 paddler_tests/tests/typesafe_models_is_unavailable_while_the_cluster_serves_another_inference_mode.rs create mode 100644 paddler_tests/tests/typesafe_system_one_is_unavailable_while_the_cluster_serves_another_inference_mode.rs create mode 100644 resources/ts/components/TokenGenerationDisabledContextProvider.tsx create mode 100644 resources/ts/components/TokenGenerationDisabledNotice.module.css create mode 100644 resources/ts/components/TokenGenerationDisabledNotice.tsx create mode 100644 resources/ts/contexts/TokenGenerationDisabledContext.ts delete mode 100644 resources/ts/embeddingParametersOf.ts delete mode 100644 resources/ts/textGenerationSettingsOf.ts diff --git a/.claude/rules/paddler-balancer.md b/.claude/rules/paddler-balancer.md index 325713b24..795bd1722 100644 --- a/.claude/rules/paddler-balancer.md +++ b/.claude/rules/paddler-balancer.md @@ -7,7 +7,15 @@ paths: - `paddler_balancer` crate is responsible for starting inference, and management servers - Paddler Agents connect to the balancer in order to handle the requests that the balancer dispatches -- `paddler_balancer` provides compatibility services that expose vendor-compatible APIs: OpenAI compatibility in text-generation mode (built on `paddler_openai_translation`), and TypeSafe System One compatibility in decision mode (built on `paddler_typesafe_translation`) +- `paddler_balancer` provides compatibility services that expose vendor-compatible APIs: OpenAI compatibility (built on `paddler_openai_translation`), and TypeSafe System One compatibility (built on `paddler_typesafe_translation`); each one is served whenever its address is configured + +# Inference Modes + +- the balancer desired state is the only source of truth for the inference mode; reconciliation transforms it into the applicable state, and agents receive only the active mode's settings +- every route is registered in every mode; each handler first checks the applied mode with `BalancerApplicableStateHolder::require_inference_mode`, or reads the applied settings once and matches the variant it needs +- a request of another mode is refused with `ClusterServesAnotherInferenceMode`: 503 over HTTP and in every compatibility layer, an in-band `InferenceModeMismatch` over the inference socket +- the agent pool dispatches by free capacity only; an agent still serving the previous mode answers `InferenceModeMismatch` +- the inference socket notifies its clients of the cluster's inference mode on connect and whenever it changes # Compatibility Layers diff --git a/.claude/rules/paddler-test-cluster-harness.md b/.claude/rules/paddler-test-cluster-harness.md index b754681a3..bf27ad825 100644 --- a/.claude/rules/paddler-test-cluster-harness.md +++ b/.claude/rules/paddler-test-cluster-harness.md @@ -16,3 +16,4 @@ paths: - TypeSafe publishes no Rust SDK, so the harness's `TypeSafeApiClient` is a thin `reqwest` client built on the balancer's `TypeSafeApiPath` and `TypeSafeHeader` - the official `typesafe-sdk` is exercised by `paddler_typesafe_client_python_test`, which keeps the compatibility claim objective - both layers expose the same harness surface: `RunningBalancer::compat__addr()`, `compat__base_url()`, and `Cluster::compat__health_client()` +- test clusters always serve both compatibility services; a test picks the cluster's inference mode through its desired state diff --git a/README.md b/README.md index e128f4df9..b30041895 100644 --- a/README.md +++ b/README.md @@ -42,18 +42,18 @@ You can obtain the binary by: Once you have made the binary available in your system, you can start using Paddler. The entire Paddler functionality is available through the `paddler` command (running `paddler --help` will list all available commands). -There are only two deployable components, the `balancer` (which distributes the incoming requests), and the `agent` (which generates tokens or embeddings through slots). +There are only two deployable components, the `balancer` (which distributes the incoming requests), and the `agent` (which generates tokens, embeddings, or decisions through slots). -Each cluster serves one inference mode, chosen when the balancer starts: `text-generation`, `embeddings`, or `decision`. To start a balancer that generates text, run: +To start the balancer, run: ```sh -paddler balancer text-generation --inference-addr 127.0.0.1:8061 --management-addr 127.0.0.1:8060 --compat-openai-addr 127.0.0.1:8063 --web-admin-panel-addr 127.0.0.1:8062 +paddler balancer --inference-addr 127.0.0.1:8061 --management-addr 127.0.0.1:8060 --compat-openai-addr 127.0.0.1:8063 --compat-typesafe-addr 127.0.0.1:8009 --web-admin-panel-addr 127.0.0.1:8062 ``` -The `--web-admin-panel-addr` flag is optional, but it will allow you to view your setup in a web browser. The `--compat-openai-addr` flag is optional too; it serves the OpenAI-compatible API (`POST /v1/chat/completions` and `POST /v1/responses`), so OpenAI clients only need to change their base URL. +The `--web-admin-panel-addr` flag is optional, but it will allow you to view your setup in a web browser. The `--compat-openai-addr` flag is optional too; it serves the OpenAI-compatible API (`POST /v1/chat/completions` and `POST /v1/responses`), so OpenAI clients only need to change their base URL. The `--compat-typesafe-addr` flag serves the TypeSafe-compatible System One API (`POST /v1/systemone`) the same way. -To serve embeddings instead, start the balancer with `paddler balancer embeddings` and the same flags (without `--compat-openai-addr`). The state database remembers the mode, and a balancer refuses to start from a state database of another mode. +A cluster serves one inference mode at a time: `TextGeneration` (the default), `Embeddings`, or `Decision`. The mode is part of the balancer desired state, next to the settings of every mode, so you can switch it at runtime on the web admin panel's Model page or with `PUT /api/v1/balancer_desired_state`. Agents reload into the new mode, requests already running finish, and requests of another mode are answered with `503 Service Unavailable`. Inference socket clients are notified of the cluster's inference mode when they connect and whenever it changes. -`paddler balancer decision` serves [kev](https://github.com/jaredpalmer/kev) decision models: it reads a document and answers multiple-choice questions about it with calibrated probabilities, without generating text (`POST /api/v1/decide`). A decision model is a Qwen3.5 GGUF plus its pointer head; `make target/kev/model.gguf target/kev/pointer_head.gguf` converts a kev checkpoint into both files. Decision agents need at least two slots. Add `--compat-typesafe-addr` to serve the TypeSafe-compatible System One API (`POST /v1/systemone`) as well. +The `Decision` mode serves [kev](https://github.com/jaredpalmer/kev) decision models: it reads a document and answers multiple-choice questions about it with calibrated probabilities, without generating text (`POST /api/v1/decide`). A decision model is a Qwen3.5 GGUF plus its pointer head; `make target/kev/model.gguf target/kev/pointer_head.gguf` converts a kev checkpoint into both files. Decision agents need at least two slots. And to start an agent with, for example, 4 slots, run: @@ -71,14 +71,14 @@ Paddler serves [kev](https://github.com/jaredpalmer/kev) decision models behind make target/kev/model.gguf target/kev/pointer_head.gguf ``` -Start a decision balancer with the TypeSafe-compatible API, and an agent with at least two slots: +Start a balancer with the TypeSafe-compatible API, and an agent with at least two slots: ```sh -paddler balancer decision --inference-addr 127.0.0.1:8061 --management-addr 127.0.0.1:8060 --compat-typesafe-addr 127.0.0.1:8009 --web-admin-panel-addr 127.0.0.1:8062 +paddler balancer --inference-addr 127.0.0.1:8061 --management-addr 127.0.0.1:8060 --compat-typesafe-addr 127.0.0.1:8009 --web-admin-panel-addr 127.0.0.1:8062 paddler agent --management-addr 127.0.0.1:8060 --slots 2 ``` -In the web admin panel, set the model to `agent:///absolute/path/to/target/kev/model.gguf` and the pointer head to `agent:///absolute/path/to/target/kev/pointer_head.gguf`, then ask questions with a TypeSafe client or `curl`: +In the web admin panel, choose the `Decision` inference mode, set the model to `agent:///absolute/path/to/target/kev/model.gguf` and the pointer head to `agent:///absolute/path/to/target/kev/pointer_head.gguf`, then ask questions with a TypeSafe client or `curl`: ```sh curl -s localhost:8009/v1/systemone -H 'content-type: application/json' -d '{ diff --git a/flake.nix b/flake.nix index ce99615ea..81030979a 100644 --- a/flake.nix +++ b/flake.nix @@ -298,7 +298,6 @@ in [ "balancer" - balancer.inferenceMode "--management-addr" balancer.managementAddr "--inference-addr" @@ -376,26 +375,16 @@ description = "Address of the web admin panel. Disabled when null."; }; - inferenceMode = lib.mkOption { - type = lib.types.enum [ - "decision" - "embeddings" - "text-generation" - ]; - default = "text-generation"; - description = "The inference mode the cluster serves. It is fixed when the balancer starts."; - }; - openaiCompatAddr = lib.mkOption { type = lib.types.nullOr socketAddrType; default = null; - description = "Address of the OpenAI-compatible API server, served only in text-generation mode. When null it is disabled."; + description = "Address of the OpenAI-compatible API server. When null it is disabled."; }; typesafeCompatAddr = lib.mkOption { type = lib.types.nullOr socketAddrType; default = null; - description = "Address of the TypeSafe-compatible System One API server, served only in decision mode. When null it is disabled."; + description = "Address of the TypeSafe-compatible System One API server. When null it is disabled."; }; stateDatabase = lib.mkOption { @@ -425,7 +414,7 @@ openFirewall = lib.mkOption { type = lib.types.bool; default = false; - description = "Open the management, inference, web admin panel and OpenAI-compatible ports in the firewall."; + description = "Open the management, inference, web admin panel, OpenAI-compatible and TypeSafe-compatible ports in the firewall."; }; }; @@ -518,18 +507,6 @@ }) (lib.mkIf cfg.balancer.enable { - assertions = [ - { - assertion = - cfg.balancer.openaiCompatAddr != null -> cfg.balancer.inferenceMode == "text-generation"; - message = "services.paddler.balancer.openaiCompatAddr is only served in text-generation mode."; - } - { - assertion = cfg.balancer.typesafeCompatAddr != null -> cfg.balancer.inferenceMode == "decision"; - message = "services.paddler.balancer.typesafeCompatAddr is only served in decision mode."; - } - ]; - systemd.services.paddler-balancer = { description = "Paddler balancer"; after = [ "network-online.target" ]; @@ -558,7 +535,8 @@ (portOf cfg.balancer.inferenceAddr) ] ++ lib.optional (cfg.balancer.webAdminPanelAddr != null) (portOf cfg.balancer.webAdminPanelAddr) - ++ lib.optional (cfg.balancer.openaiCompatAddr != null) (portOf cfg.balancer.openaiCompatAddr); + ++ lib.optional (cfg.balancer.openaiCompatAddr != null) (portOf cfg.balancer.openaiCompatAddr) + ++ lib.optional (cfg.balancer.typesafeCompatAddr != null) (portOf cfg.balancer.typesafeCompatAddr); }; }) diff --git a/jarmuz/worker-paddler.mjs b/jarmuz/worker-paddler.mjs index 1864ace43..7069a6c27 100644 --- a/jarmuz/worker-paddler.mjs +++ b/jarmuz/worker-paddler.mjs @@ -17,8 +17,9 @@ spawner(async function ({ buildId, command }) { const results = await Promise.all([ command(` - target/debug/paddler balancer text-generation + target/debug/paddler balancer --compat-openai-addr 127.0.0.1:8063 + --compat-typesafe-addr 127.0.0.1:8009 --inference-addr 127.0.0.1:8061 --inference-item-timeout 30000 --management-addr 127.0.0.1:8060 diff --git a/paddler_agent/src/management_socket_client_service.rs b/paddler_agent/src/management_socket_client_service.rs index 7cf6de3a6..188e0c9b4 100644 --- a/paddler_agent/src/management_socket_client_service.rs +++ b/paddler_agent/src/management_socket_client_service.rs @@ -479,7 +479,6 @@ mod tests { use paddler_messaging::model_metadata::ModelMetadata; use paddler_messaging::request_params::continue_from_raw_prompt_params::ContinueFromRawPromptParams; use paddler_messaging::slot_aggregated_status_snapshot::SlotAggregatedStatusSnapshot; - use paddler_messaging::inference_mode::InferenceMode; use paddler_request_registry::request_delivery::RequestDelivery; use paddler_request_registry::request_registry_guard::RequestRegistryGuard; use paddler_agent_runtime::model_metadata_holder::ModelMetadataHolder; @@ -542,9 +541,7 @@ mod tests { } fn unconfigured_desired_state() -> AgentDesiredState { - AgentDesiredState::from(BalancerDesiredState::unconfigured( - InferenceMode::TextGeneration, - )) + AgentDesiredState::from(BalancerDesiredState::default()) } fn set_state_message() -> JsonRpcMessage { diff --git a/paddler_agent/src/reconciliation_service.rs b/paddler_agent/src/reconciliation_service.rs index b05c2b497..e9503b9e8 100644 --- a/paddler_agent/src/reconciliation_service.rs +++ b/paddler_agent/src/reconciliation_service.rs @@ -76,7 +76,6 @@ mod tests { use paddler_messaging::agent_desired_state::AgentDesiredState; use paddler_messaging::balancer_desired_state::BalancerDesiredState; use paddler_messaging::huggingface_model_reference::HuggingFaceModelReference; - use paddler_messaging::inference_mode::InferenceMode; use crate::agent_applicable_state::AgentApplicableState; use crate::agent_applicable_state_holder::AgentApplicableStateHolder; @@ -86,9 +85,7 @@ mod tests { fn desired_state_with_model(model: AgentDesiredModel) -> AgentDesiredState { AgentDesiredState { model, - ..AgentDesiredState::from(BalancerDesiredState::unconfigured( - InferenceMode::TextGeneration, - )) + ..AgentDesiredState::from(BalancerDesiredState::default()) } } diff --git a/paddler_balancer/src/agent_controller_pool.rs b/paddler_balancer/src/agent_controller_pool.rs index 0235510d8..0b810b7b9 100644 --- a/paddler_balancer/src/agent_controller_pool.rs +++ b/paddler_balancer/src/agent_controller_pool.rs @@ -8,7 +8,6 @@ use tokio::sync::watch; use paddler_messaging::agent_controller_pool_snapshot::AgentControllerPoolSnapshot; use paddler_messaging::agent_desired_state::AgentDesiredState; -use paddler_messaging::inference_mode::InferenceMode; use paddler_messaging::produces_snapshot::ProducesSnapshot; use paddler_messaging::subscribes_to_updates::SubscribesToUpdates; @@ -22,22 +21,13 @@ use crate::dispatch_candidate::DispatchCandidate; use crate::dispatched_agent::DispatchedAgent; use crate::registered_agent_controller_guard::RegisteredAgentControllerGuard; +#[derive(Default)] pub struct AgentControllerPool { pub agents: DashMap>, - inference_mode: InferenceMode, updates: Arc, } impl AgentControllerPool { - #[must_use] - pub fn new(inference_mode: InferenceMode) -> Self { - Self { - agents: DashMap::new(), - inference_mode, - updates: Arc::new(AgentControllerPoolUpdates::default()), - } - } - #[must_use] pub fn select_least_busy_with_capacity(&self) -> Option { let mut best: Option = None; @@ -45,9 +35,14 @@ impl AgentControllerPool { for entry in &self.agents { let agent_controller = entry.value().clone(); let snapshot = agent_controller.slots_processing.get(); - let runtime = agent_controller.reported_status.read().status.runtime; - - if !runtime.serves(self.inference_mode) || snapshot >= runtime.slots_total() { + let slots_total = agent_controller + .reported_status + .read() + .status + .runtime + .slots_total(); + + if snapshot >= slots_total { continue; } @@ -258,13 +253,10 @@ mod tests { } #[test] - fn dispatches_only_to_agents_serving_the_cluster_mode() { - let pool = Arc::new(AgentControllerPool::new(InferenceMode::Embeddings)); + fn dispatches_only_to_agents_with_free_slots() { + let pool = Arc::new(AgentControllerPool::default()); let registrations = [ - pool.register_agent_controller( - agent_reporting("text-generation", serving(InferenceMode::TextGeneration)) - .controller, - ), + pool.register_agent_controller(agent_with_inbox("idle").controller), pool.register_agent_controller( agent_reporting("embeddings", serving(InferenceMode::Embeddings)).controller, ), @@ -283,7 +275,7 @@ mod tests { #[test] fn counts_only_the_slots_of_serving_agents() { - let pool = Arc::new(AgentControllerPool::new(InferenceMode::TextGeneration)); + let pool = Arc::new(AgentControllerPool::default()); let _registrations = [ pool.register_agent_controller(agent_with_inbox("idle").controller), pool.register_agent_controller( @@ -296,7 +288,7 @@ mod tests { #[test] fn delivers_the_desired_state_to_connected_agents_past_a_disconnected_one() { - let pool = Arc::new(AgentControllerPool::new(InferenceMode::TextGeneration)); + let pool = Arc::new(AgentControllerPool::default()); let disconnected_agent = agent_with_inbox("disconnected"); let mut connected_agent = agent_with_inbox("connected"); @@ -312,15 +304,13 @@ mod tests { AgentControllerRegistration::Registered(_) ))); - pool.set_desired_state(&AgentDesiredState::from( - BalancerDesiredState::unconfigured(InferenceMode::TextGeneration), - )); + pool.set_desired_state(&AgentDesiredState::from(BalancerDesiredState::default())); assert!(matches!( connected_agent.inbox.try_recv(), Ok(AgentJsonRpcMessage::Notification( AgentJsonRpcNotification::SetState(set_state_params) - )) if set_state_params.desired_state == AgentDesiredState::from(BalancerDesiredState::unconfigured(InferenceMode::TextGeneration)) + )) if set_state_params.desired_state == AgentDesiredState::from(BalancerDesiredState::default()) )); } } diff --git a/paddler_balancer/src/balancer_applicable_state_holder.rs b/paddler_balancer/src/balancer_applicable_state_holder.rs index 01c844e53..eec6ba04b 100644 --- a/paddler_balancer/src/balancer_applicable_state_holder.rs +++ b/paddler_balancer/src/balancer_applicable_state_holder.rs @@ -2,9 +2,11 @@ use parking_lot::RwLock; use tokio::sync::watch; use paddler_messaging::agent_desired_state::AgentDesiredState; +use paddler_messaging::inference_mode::InferenceMode; use paddler_messaging::subscribes_to_updates::SubscribesToUpdates; use crate::balancer_applicable_state::BalancerApplicableState; +use crate::cluster_serves_another_inference_mode::ClusterServesAnotherInferenceMode; pub struct BalancerApplicableStateHolder { balancer_applicable_state: RwLock, @@ -33,6 +35,31 @@ impl BalancerApplicableStateHolder { self.balancer_applicable_state.read().clone() } + #[must_use] + pub fn inference_mode(&self) -> InferenceMode { + self.balancer_applicable_state + .read() + .agent_desired_state + .inference_settings + .inference_mode() + } + + pub fn require_inference_mode( + &self, + requested_inference_mode: InferenceMode, + ) -> Result<(), ClusterServesAnotherInferenceMode> { + let served_inference_mode = self.inference_mode(); + + if served_inference_mode == requested_inference_mode { + Ok(()) + } else { + Err(ClusterServesAnotherInferenceMode { + requested_inference_mode, + served_inference_mode, + }) + } + } + pub fn set_balancer_applicable_state( &self, balancer_applicable_state: BalancerApplicableState, @@ -58,17 +85,23 @@ mod tests { use super::BalancerApplicableStateHolder; use crate::balancer_applicable_state::BalancerApplicableState; + use crate::cluster_serves_another_inference_mode::ClusterServesAnotherInferenceMode; + + fn holder_serving(inference_mode: InferenceMode) -> BalancerApplicableStateHolder { + BalancerApplicableStateHolder::new(BalancerApplicableState::from(BalancerDesiredState { + inference_mode, + ..BalancerDesiredState::default() + })) + } #[test] fn replacing_the_state_notifies_subscribers_and_is_read_back() { - let holder = BalancerApplicableStateHolder::new(BalancerApplicableState::from( - BalancerDesiredState::unconfigured(InferenceMode::TextGeneration), - )); + let holder = holder_serving(InferenceMode::TextGeneration); let update_rx = holder.subscribe_to_updates(); holder.set_balancer_applicable_state(BalancerApplicableState::from(BalancerDesiredState { model: AgentDesiredModel::LocalToAgent("model.gguf".to_owned()), - ..BalancerDesiredState::unconfigured(InferenceMode::TextGeneration) + ..BalancerDesiredState::default() })); assert!( @@ -81,4 +114,21 @@ mod tests { AgentDesiredModel::LocalToAgent("model.gguf".to_owned()) ); } + + #[test] + fn requires_the_inference_mode_the_applied_state_serves() { + let holder = holder_serving(InferenceMode::Embeddings); + + assert_eq!( + holder.require_inference_mode(InferenceMode::Embeddings), + Ok(()) + ); + assert_eq!( + holder.require_inference_mode(InferenceMode::TextGeneration), + Err(ClusterServesAnotherInferenceMode { + requested_inference_mode: InferenceMode::TextGeneration, + served_inference_mode: InferenceMode::Embeddings, + }) + ); + } } diff --git a/paddler_balancer/src/buffered_request_manager.rs b/paddler_balancer/src/buffered_request_manager.rs index e1f300b28..3f1cce2a9 100644 --- a/paddler_balancer/src/buffered_request_manager.rs +++ b/paddler_balancer/src/buffered_request_manager.rs @@ -100,7 +100,6 @@ mod tests { use tokio_util::sync::CancellationToken; use paddler_messaging::atomic_value::AtomicValue; - use paddler_messaging::inference_mode::InferenceMode; use paddler_messaging::subscribes_to_updates::SubscribesToUpdates as _; use super::BufferedRequestManager; @@ -117,7 +116,7 @@ mod tests { fn race_callers_for_one_buffered_place(racing_callers: usize) -> RaceOutcome { let buffered_request_manager = BufferedRequestManager::new( - Arc::new(AgentControllerPool::new(InferenceMode::TextGeneration)), + Arc::new(AgentControllerPool::default()), Duration::MAX, BUFFER_CAPACITY, ); diff --git a/paddler_balancer/src/cluster_serves_another_inference_mode.rs b/paddler_balancer/src/cluster_serves_another_inference_mode.rs new file mode 100644 index 000000000..69f76abc0 --- /dev/null +++ b/paddler_balancer/src/cluster_serves_another_inference_mode.rs @@ -0,0 +1,44 @@ +use std::fmt; +use std::fmt::Display; +use std::fmt::Formatter; + +use paddler_messaging::inference_mode::InferenceMode; + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct ClusterServesAnotherInferenceMode { + pub requested_inference_mode: InferenceMode, + pub served_inference_mode: InferenceMode, +} + +impl Display for ClusterServesAnotherInferenceMode { + fn fmt(&self, formatter: &mut Formatter<'_>) -> fmt::Result { + let Self { + requested_inference_mode, + served_inference_mode, + } = self; + + write!( + formatter, + "The cluster serves {served_inference_mode:?}, not {requested_inference_mode:?}" + ) + } +} + +#[cfg(test)] +mod tests { + use paddler_messaging::inference_mode::InferenceMode; + + use super::ClusterServesAnotherInferenceMode; + + #[test] + fn names_the_served_and_the_requested_inference_mode() { + assert_eq!( + ClusterServesAnotherInferenceMode { + requested_inference_mode: InferenceMode::TextGeneration, + served_inference_mode: InferenceMode::Embeddings, + } + .to_string(), + "The cluster serves Embeddings, not TextGeneration" + ); + } +} diff --git a/paddler_balancer/src/compatibility/agent_result_stream_transformer.rs b/paddler_balancer/src/compatibility/agent_result_stream_transformer.rs index d4f6ced78..36e63194f 100644 --- a/paddler_balancer/src/compatibility/agent_result_stream_transformer.rs +++ b/paddler_balancer/src/compatibility/agent_result_stream_transformer.rs @@ -38,6 +38,11 @@ where OutgoingMessage::Error(ErrorEnvelope { error, .. }) => { Ok(vec![AgentResultStreamEvent::WireError(error)]) } + notification @ OutgoingMessage::Notification(_) => { + Err(AgentRelayError::MessageNotRelayable { + message: Box::new(notification), + }) + } OutgoingMessage::Response(ResponseEnvelope { generated_by, request_id, @@ -61,7 +66,9 @@ mod tests { use paddler_messaging::decision_result::DecisionResult; use paddler_messaging::embedding_result::EmbeddingResult; use paddler_messaging::inference_client::message::Message as OutgoingMessage; + use paddler_messaging::inference_client::notification::Notification; use paddler_messaging::inference_client::response::Response as OutgoingResponse; + use paddler_messaging::inference_mode::InferenceMode; use paddler_messaging::jsonrpc::error::Error as JsonRpcError; use paddler_messaging::jsonrpc::error_envelope::ErrorEnvelope; use paddler_messaging::jsonrpc::response_envelope::ResponseEnvelope; @@ -127,4 +134,22 @@ mod tests { ) )); } + + #[tokio::test] + async fn refuses_to_relay_a_cluster_notification() { + assert!(matches!( + AgentResultStreamTransformer::::default() + .transform(OutgoingMessage::Notification( + Notification::ClusterInferenceMode(InferenceMode::Decision) + )) + .await, + Err(AgentRelayError::MessageNotRelayable { message }) + if matches!( + *message, + OutgoingMessage::Notification(Notification::ClusterInferenceMode( + InferenceMode::Decision + )) + ) + )); + } } diff --git a/paddler_balancer/src/compatibility/openai_service/http_route/post_chat_completions.rs b/paddler_balancer/src/compatibility/openai_service/http_route/post_chat_completions.rs index 5e51c39bb..c6cfbd242 100644 --- a/paddler_balancer/src/compatibility/openai_service/http_route/post_chat_completions.rs +++ b/paddler_balancer/src/compatibility/openai_service/http_route/post_chat_completions.rs @@ -4,6 +4,7 @@ use actix_web::HttpResponse; use actix_web::web; use actix_web::web::post; +use paddler_messaging::inference_mode::InferenceMode; use paddler_openai_translation::chat_completion_delivery::ChatCompletionDelivery; use paddler_openai_translation::chat_completion_request::ChatCompletionRequest; use paddler_openai_translation::translated_chat_completion_request::TranslatedChatCompletionRequest; @@ -19,6 +20,16 @@ async fn respond( app_data: web::Data, chat_completion_request: web::Json, ) -> HttpResponse { + if let Err(cluster_serves_another_inference_mode) = app_data + .balancer_applicable_state_holder + .require_inference_mode(InferenceMode::TextGeneration) + { + return OpenAIFailure::ClusterServesAnotherInferenceMode( + cluster_serves_another_inference_mode, + ) + .into_http_response(); + } + match chat_completion_request .into_inner() .translate(SystemTime::now()) @@ -70,7 +81,6 @@ mod tests { use tokio_util::sync::CancellationToken; use paddler_messaging::balancer_desired_state::BalancerDesiredState; - use paddler_messaging::inference_mode::InferenceMode; use paddler_openai_response_format_validator::openai_validator::OpenAIValidator; use super::CompatibilityAppData; @@ -86,12 +96,10 @@ mod tests { fn app_data_without_agents(max_buffered_requests: u64) -> CompatibilityAppData { CompatibilityAppData { balancer_applicable_state_holder: Arc::new(BalancerApplicableStateHolder::new( - BalancerApplicableState::from(BalancerDesiredState::unconfigured( - InferenceMode::TextGeneration, - )), + BalancerApplicableState::from(BalancerDesiredState::default()), )), buffered_request_manager: Arc::new(BufferedRequestManager::new( - Arc::new(AgentControllerPool::new(InferenceMode::TextGeneration)), + Arc::new(AgentControllerPool::default()), Duration::ZERO, max_buffered_requests, )), diff --git a/paddler_balancer/src/compatibility/openai_service/http_route/post_responses.rs b/paddler_balancer/src/compatibility/openai_service/http_route/post_responses.rs index c5c2bd6eb..697a9aa7a 100644 --- a/paddler_balancer/src/compatibility/openai_service/http_route/post_responses.rs +++ b/paddler_balancer/src/compatibility/openai_service/http_route/post_responses.rs @@ -4,10 +4,14 @@ use actix_web::HttpResponse; use actix_web::web; use actix_web::web::post; +use paddler_messaging::agent_inference_settings::AgentInferenceSettings; +use paddler_messaging::agent_text_generation_settings::AgentTextGenerationSettings; +use paddler_messaging::inference_mode::InferenceMode; use paddler_openai_translation::responses_delivery::ResponsesDelivery; use paddler_openai_translation::responses_request::ResponsesRequest; use paddler_openai_translation::translated_responses_request::TranslatedResponsesRequest; +use crate::cluster_serves_another_inference_mode::ClusterServesAnotherInferenceMode; use crate::compatibility::compatibility_app_data::CompatibilityAppData; use crate::compatibility::openai_service::openai_api_path::OpenAIApiPath; use crate::compatibility::openai_service::openai_failure::OpenAIFailure; @@ -19,13 +23,30 @@ async fn respond( app_data: web::Data, responses_request: web::Json, ) -> HttpResponse { - let agent_desired_state = app_data + let sampling_parameters = match app_data .balancer_applicable_state_holder - .get_agent_desired_state(); + .get_agent_desired_state() + .inference_settings + { + AgentInferenceSettings::TextGeneration(AgentTextGenerationSettings { + sampling_parameters, + .. + }) => sampling_parameters, + served_inference_settings @ (AgentInferenceSettings::Decision(_) + | AgentInferenceSettings::Embeddings(_)) => { + return OpenAIFailure::ClusterServesAnotherInferenceMode( + ClusterServesAnotherInferenceMode { + requested_inference_mode: InferenceMode::TextGeneration, + served_inference_mode: served_inference_settings.inference_mode(), + }, + ) + .into_http_response(); + } + }; match responses_request .into_inner() - .translate(SystemTime::now(), &agent_desired_state.inference_settings) + .translate(SystemTime::now(), &sampling_parameters) { Ok(TranslatedResponsesRequest { conversation_history_params, diff --git a/paddler_balancer/src/compatibility/openai_service/openai_failure.rs b/paddler_balancer/src/compatibility/openai_service/openai_failure.rs index 94bd49a0c..2147fcd09 100644 --- a/paddler_balancer/src/compatibility/openai_service/openai_failure.rs +++ b/paddler_balancer/src/compatibility/openai_service/openai_failure.rs @@ -6,11 +6,13 @@ use paddler_openai_translation::generation_failure::GenerationFailure; use paddler_openai_translation::generation_failure_cause::GenerationFailureCause; use paddler_openai_translation::openai_translation_error::OpenAITranslationError; +use crate::cluster_serves_another_inference_mode::ClusterServesAnotherInferenceMode; use crate::compatibility::openai_service::openai_error_body::OpenAIErrorBody; use crate::compatibility::openai_service::openai_error_type::OpenAIErrorType; use crate::compatibility::upstream_failure::UpstreamFailure; pub enum OpenAIFailure { + ClusterServesAnotherInferenceMode(ClusterServesAnotherInferenceMode), Generation(GenerationFailure), RequestUntranslatable(OpenAITranslationError), Unfinished, @@ -41,6 +43,9 @@ impl OpenAIFailure { fn into_message(self) -> String { match self { + Self::ClusterServesAnotherInferenceMode(cluster_serves_another_inference_mode) => { + cluster_serves_another_inference_mode.to_string() + } Self::Generation(GenerationFailure { message, .. }) => message, Self::RequestUntranslatable(translation_error) => translation_error.to_string(), Self::Unfinished => "the agent stopped before it finished the generation".to_owned(), @@ -57,12 +62,10 @@ impl OpenAIFailure { | Self::RequestUntranslatable(OpenAITranslationError::ToolRejected(_)) => { StatusCode::BAD_REQUEST } - Self::Generation(GenerationFailure { + Self::ClusterServesAnotherInferenceMode(_) + | Self::Generation(GenerationFailure { cause: GenerationFailureCause::Unavailable, .. - }) - | Self::RequestUntranslatable(OpenAITranslationError::InferenceModeMismatch { - .. }) => UpstreamFailure::Unavailable.status_code(), Self::Generation(GenerationFailure { cause: GenerationFailureCause::AgentFailed, @@ -96,6 +99,7 @@ mod tests { use paddler_openai_translation::openai_translation_error::OpenAITranslationError; use super::OpenAIFailure; + use crate::cluster_serves_another_inference_mode::ClusterServesAnotherInferenceMode; fn error_body(message: &str, error_type: &str) -> Value { json!({ @@ -150,14 +154,15 @@ mod tests { ), ), ( - OpenAIFailure::RequestUntranslatable( - OpenAITranslationError::InferenceModeMismatch { - inference_mode: InferenceMode::Embeddings, + OpenAIFailure::ClusterServesAnotherInferenceMode( + ClusterServesAnotherInferenceMode { + requested_inference_mode: InferenceMode::TextGeneration, + served_inference_mode: InferenceMode::Embeddings, }, ), StatusCode::SERVICE_UNAVAILABLE, error_body( - "the cluster serves Embeddings, not text generation", + "The cluster serves Embeddings, not TextGeneration", "server_error", ), ), diff --git a/paddler_balancer/src/compatibility/typesafe_service/http_route/get_models.rs b/paddler_balancer/src/compatibility/typesafe_service/http_route/get_models.rs index c2cd7731c..1be376907 100644 --- a/paddler_balancer/src/compatibility/typesafe_service/http_route/get_models.rs +++ b/paddler_balancer/src/compatibility/typesafe_service/http_route/get_models.rs @@ -2,17 +2,35 @@ use actix_web::HttpResponse; use actix_web::web; use actix_web::web::get; +use paddler_messaging::agent_desired_state::AgentDesiredState; +use paddler_messaging::agent_inference_settings::AgentInferenceSettings; +use paddler_messaging::inference_mode::InferenceMode; + +use crate::cluster_serves_another_inference_mode::ClusterServesAnotherInferenceMode; use crate::compatibility::compatibility_app_data::CompatibilityAppData; +use crate::compatibility::typesafe_service::inference_mode_refusal_http_response::inference_mode_refusal_http_response; use crate::compatibility::typesafe_service::typesafe_api_path::TypeSafeApiPath; use crate::compatibility::typesafe_service::typesafe_models::TypeSafeModels; async fn respond(app_data: web::Data) -> HttpResponse { - HttpResponse::Ok().json(TypeSafeModels::from( - app_data - .balancer_applicable_state_holder - .get_agent_desired_state() - .model, - )) + let AgentDesiredState { + inference_settings, + model, + .. + } = app_data + .balancer_applicable_state_holder + .get_agent_desired_state(); + + match inference_settings { + AgentInferenceSettings::Decision(_) => HttpResponse::Ok().json(TypeSafeModels::from(model)), + served_inference_settings @ (AgentInferenceSettings::Embeddings(_) + | AgentInferenceSettings::TextGeneration(_)) => { + inference_mode_refusal_http_response(ClusterServesAnotherInferenceMode { + requested_inference_mode: InferenceMode::Decision, + served_inference_mode: served_inference_settings.inference_mode(), + }) + } + } } pub fn get_models(cfg: &mut web::ServiceConfig) { diff --git a/paddler_balancer/src/compatibility/typesafe_service/http_route/post_system_one.rs b/paddler_balancer/src/compatibility/typesafe_service/http_route/post_system_one.rs index 98b03b469..957a7b725 100644 --- a/paddler_balancer/src/compatibility/typesafe_service/http_route/post_system_one.rs +++ b/paddler_balancer/src/compatibility/typesafe_service/http_route/post_system_one.rs @@ -3,9 +3,11 @@ use actix_web::http::StatusCode; use actix_web::web; use actix_web::web::post; +use paddler_messaging::inference_mode::InferenceMode; use paddler_typesafe_translation::system_one_request::SystemOneRequest; use crate::compatibility::compatibility_app_data::CompatibilityAppData; +use crate::compatibility::typesafe_service::inference_mode_refusal_http_response::inference_mode_refusal_http_response; use crate::compatibility::typesafe_service::system_one_http_response::system_one_http_response; use crate::compatibility::typesafe_service::typesafe_api_path::TypeSafeApiPath; use crate::compatibility::typesafe_service::typesafe_error_body::TypeSafeErrorBody; @@ -15,6 +17,13 @@ async fn respond( app_data: web::Data, system_one_request: web::Json, ) -> HttpResponse { + if let Err(cluster_serves_another_inference_mode) = app_data + .balancer_applicable_state_holder + .require_inference_mode(InferenceMode::Decision) + { + return inference_mode_refusal_http_response(cluster_serves_another_inference_mode); + } + match system_one_request.into_inner().translate() { Ok(translated) => { let decision_results = app_data.agent_result_stream(translated.decide_params.clone()); diff --git a/paddler_balancer/src/compatibility/typesafe_service/inference_mode_refusal_http_response.rs b/paddler_balancer/src/compatibility/typesafe_service/inference_mode_refusal_http_response.rs new file mode 100644 index 000000000..a7ca53d8e --- /dev/null +++ b/paddler_balancer/src/compatibility/typesafe_service/inference_mode_refusal_http_response.rs @@ -0,0 +1,15 @@ +use actix_web::HttpResponse; + +use crate::cluster_serves_another_inference_mode::ClusterServesAnotherInferenceMode; +use crate::compatibility::typesafe_service::typesafe_error_body::TypeSafeErrorBody; +use crate::compatibility::upstream_failure::UpstreamFailure; + +#[must_use] +pub fn inference_mode_refusal_http_response( + cluster_serves_another_inference_mode: ClusterServesAnotherInferenceMode, +) -> HttpResponse { + TypeSafeErrorBody { + detail: cluster_serves_another_inference_mode.to_string(), + } + .into_http_response(UpstreamFailure::Unavailable.status_code()) +} diff --git a/paddler_balancer/src/compatibility/typesafe_service/mod.rs b/paddler_balancer/src/compatibility/typesafe_service/mod.rs index 7cfd9d9e4..df65bca97 100644 --- a/paddler_balancer/src/compatibility/typesafe_service/mod.rs +++ b/paddler_balancer/src/compatibility/typesafe_service/mod.rs @@ -1,4 +1,5 @@ pub mod http_route; +pub mod inference_mode_refusal_http_response; pub mod system_one_http_response; pub mod typesafe_api_path; pub mod typesafe_compatibility_layer; diff --git a/paddler_balancer/src/inference_service/http_route/api/post_continue_from_conversation_history.rs b/paddler_balancer/src/inference_service/http_route/api/post_continue_from_conversation_history.rs index cd806dc04..a1e9ed90a 100644 --- a/paddler_balancer/src/inference_service/http_route/api/post_continue_from_conversation_history.rs +++ b/paddler_balancer/src/inference_service/http_route/api/post_continue_from_conversation_history.rs @@ -1,10 +1,12 @@ use actix_web::Error; use actix_web::Responder; use actix_web::error::ErrorBadRequest; +use actix_web::error::ErrorServiceUnavailable; use actix_web::web; use actix_web::web::post; use paddler_messaging::api_path::ApiPath; +use paddler_messaging::inference_mode::InferenceMode; use paddler_messaging::request_params::continue_from_conversation_history_params::ContinueFromConversationHistoryParams; use paddler_messaging::request_params::continue_from_conversation_history_params::tool::tool_params::function_call::parameters_schema::raw_parameters_schema::RawParametersSchema; use paddler_messaging::validates::Validates as _; @@ -20,6 +22,11 @@ async fn respond( app_data: web::Data, params: web::Json>, ) -> Result { + app_data + .balancer_applicable_state_holder + .require_inference_mode(InferenceMode::TextGeneration) + .map_err(ErrorServiceUnavailable)?; + let validated_params = params.into_inner().validate().map_err(|validation_error| { ErrorBadRequest(invalid_request_parameters_description(&validation_error)) })?; diff --git a/paddler_balancer/src/inference_service/http_route/api/post_continue_from_raw_prompt.rs b/paddler_balancer/src/inference_service/http_route/api/post_continue_from_raw_prompt.rs index 453d1d9ba..3f6bf816c 100644 --- a/paddler_balancer/src/inference_service/http_route/api/post_continue_from_raw_prompt.rs +++ b/paddler_balancer/src/inference_service/http_route/api/post_continue_from_raw_prompt.rs @@ -1,9 +1,11 @@ use actix_web::Error; use actix_web::Responder; +use actix_web::error::ErrorServiceUnavailable; use actix_web::web; use actix_web::web::post; use paddler_messaging::api_path::ApiPath; +use paddler_messaging::inference_mode::InferenceMode; use paddler_messaging::request_params::continue_from_raw_prompt_params::ContinueFromRawPromptParams; use crate::chunk_forwarding_session_controller::identity_transformer::IdentityTransformer; @@ -16,6 +18,11 @@ async fn respond( app_data: web::Data, params: web::Json, ) -> Result { + app_data + .balancer_applicable_state_holder + .require_inference_mode(InferenceMode::TextGeneration) + .map_err(ErrorServiceUnavailable)?; + Ok(ndjson_response(unbounded_stream_from_agent( UnboundedStreamFromAgentParams { buffered_request_manager: app_data.buffered_request_manager.clone(), diff --git a/paddler_balancer/src/inference_service/http_route/api/post_decide.rs b/paddler_balancer/src/inference_service/http_route/api/post_decide.rs index 9ba49ff49..af0dd072b 100644 --- a/paddler_balancer/src/inference_service/http_route/api/post_decide.rs +++ b/paddler_balancer/src/inference_service/http_route/api/post_decide.rs @@ -1,10 +1,12 @@ use actix_web::Error; use actix_web::Responder; use actix_web::error::ErrorBadRequest; +use actix_web::error::ErrorServiceUnavailable; use actix_web::web; use actix_web::web::post; use paddler_messaging::api_path::ApiPath; +use paddler_messaging::inference_mode::InferenceMode; use paddler_messaging::request_params::decide_params::raw_decide_params::RawDecideParams; use paddler_messaging::validates::Validates as _; @@ -19,6 +21,11 @@ async fn respond( app_data: web::Data, params: web::Json, ) -> Result { + app_data + .balancer_applicable_state_holder + .require_inference_mode(InferenceMode::Decision) + .map_err(ErrorServiceUnavailable)?; + let validated_params = params.into_inner().validate().map_err(|validation_error| { ErrorBadRequest(invalid_request_parameters_description(&validation_error)) })?; diff --git a/paddler_balancer/src/inference_service/http_route/api/post_generate_embedding_batch.rs b/paddler_balancer/src/inference_service/http_route/api/post_generate_embedding_batch.rs index eb9c94acf..9ec7e6070 100644 --- a/paddler_balancer/src/inference_service/http_route/api/post_generate_embedding_batch.rs +++ b/paddler_balancer/src/inference_service/http_route/api/post_generate_embedding_batch.rs @@ -1,9 +1,6 @@ -use std::num::NonZeroUsize; - use actix_web::Error; use actix_web::HttpResponse; use actix_web::Responder; -use actix_web::error::ErrorInternalServerError; use actix_web::error::ErrorServiceUnavailable; use actix_web::http::header; use actix_web::rt; @@ -25,6 +22,7 @@ use paddler_messaging::api_path::ApiPath; use paddler_messaging::embedding_result::EmbeddingResult; use paddler_messaging::inference_client::message::Message as OutgoingMessage; use paddler_messaging::inference_client::response::Response as OutgoingResponse; +use paddler_messaging::inference_mode::InferenceMode; use paddler_messaging::jsonrpc::response_envelope::ResponseEnvelope; use paddler_messaging::request_params::generate_embedding_batch_params::GenerateEmbeddingBatchParams; @@ -34,6 +32,7 @@ use crate::chunk_forwarding_session_controller::ChunkForwardingSessionController use crate::chunk_forwarding_session_controller::identity_transformer::IdentityTransformer; use crate::chunk_forwarding_session_controller::transform_result::TransformResult; use crate::chunk_forwarding_session_controller::transforms_outgoing_message::TransformsOutgoingMessage; +use crate::cluster_serves_another_inference_mode::ClusterServesAnotherInferenceMode; use crate::controls_session::ControlsSession as _; use crate::embedding_chunk_dispatch::EmbeddingChunkDispatch; use crate::inference_service::app_data::AppData; @@ -44,21 +43,22 @@ async fn respond( app_data: web::Data, params: web::Json, ) -> Result { - let AgentInferenceSettings::Embeddings(EmbeddingParameters { + let EmbeddingParameters { embedding_batch_size, .. - }) = app_data + } = match app_data .balancer_applicable_state_holder .get_agent_desired_state() .inference_settings - else { - return Err(ErrorInternalServerError( - "The cluster does not serve embeddings", - )); - }; - - let Some(agent_count) = NonZeroUsize::new(app_data.agent_controller_pool.agents.len()) else { - return Err(ErrorServiceUnavailable("No agents are currently connected")); + { + AgentInferenceSettings::Embeddings(embedding_parameters) => embedding_parameters, + served_inference_settings @ (AgentInferenceSettings::Decision(_) + | AgentInferenceSettings::TextGeneration(_)) => { + return Err(ErrorServiceUnavailable(ClusterServesAnotherInferenceMode { + requested_inference_mode: InferenceMode::Embeddings, + served_inference_mode: served_inference_settings.inference_mode(), + })); + } }; let connection_close = CancellationToken::new(); @@ -67,9 +67,10 @@ async fn respond( let mut chunk_tasks: JoinSet<()> = JoinSet::new(); - let batches = params - .into_inner() - .chunk_evenly_with_cap(agent_count, embedding_batch_size); + let batches = params.into_inner().chunk_evenly_with_cap( + app_data.agent_controller_pool.agents.len(), + embedding_batch_size, + ); let chunk_dispatches: Vec = batches .into_iter() diff --git a/paddler_balancer/src/inference_service/http_route/api/ws_inference_socket/inference_socket_controller_context.rs b/paddler_balancer/src/inference_service/http_route/api/ws_inference_socket/inference_socket_controller_context.rs index 35f76112d..c87d25681 100644 --- a/paddler_balancer/src/inference_service/http_route/api/ws_inference_socket/inference_socket_controller_context.rs +++ b/paddler_balancer/src/inference_service/http_route/api/ws_inference_socket/inference_socket_controller_context.rs @@ -4,10 +4,12 @@ use tokio_util::sync::CancellationToken; use paddler_request_registry::request_registry::RequestRegistry; +use crate::balancer_applicable_state_holder::BalancerApplicableStateHolder; use crate::buffered_request_manager::BufferedRequestManager; use crate::inference_service::configuration::Configuration as InferenceServiceConfiguration; pub struct InferenceSocketControllerContext { + pub balancer_applicable_state_holder: Arc, pub buffered_request_manager: Arc, pub inference_service_configuration: InferenceServiceConfiguration, pub request_cancellation_tokens: Arc>, diff --git a/paddler_balancer/src/inference_service/http_route/api/ws_inference_socket/mod.rs b/paddler_balancer/src/inference_service/http_route/api/ws_inference_socket/mod.rs index 43261c0dc..f74d143ca 100644 --- a/paddler_balancer/src/inference_service/http_route/api/ws_inference_socket/mod.rs +++ b/paddler_balancer/src/inference_service/http_route/api/ws_inference_socket/mod.rs @@ -1,4 +1,5 @@ mod inference_socket_controller_context; +mod spawn_cluster_inference_mode_watcher; use std::fmt::Debug; use std::sync::Arc; @@ -20,23 +21,30 @@ use serde_json::from_str; use tokio_util::sync::CancellationToken; use paddler_messaging::api_path::ApiPath; +use paddler_messaging::generated_token_result::GeneratedTokenResult; use paddler_messaging::inference_client::message::Message as OutgoingMessage; use paddler_messaging::inference_client::response::Response as OutgoingResponse; +use paddler_messaging::inference_mode::InferenceMode; use paddler_messaging::inference_server::identified_request::IdentifiedRequest; use paddler_messaging::inference_server::message::Message as InferenceServerMessage; use paddler_messaging::inference_server::notification::Notification as InferenceServerNotification; use paddler_messaging::inference_server::request::Request as InferenceServerRequest; use paddler_messaging::jsonrpc::error::Error as JsonRpcError; use paddler_messaging::jsonrpc::request_envelope::RequestEnvelope; +use paddler_messaging::jsonrpc::response_envelope::ResponseEnvelope; use paddler_messaging::request_params::continue_from_conversation_history_params::tool::tool_params::function_call::parameters_schema::raw_parameters_schema::RawParametersSchema; use paddler_messaging::streamable_result::StreamableResult; use paddler_messaging::validates::Validates as _; use paddler_request_registry::request_registry_guard::RequestRegistryGuard; use self::inference_socket_controller_context::InferenceSocketControllerContext; +use self::spawn_cluster_inference_mode_watcher::spawn_cluster_inference_mode_watcher; use crate::agent_streaming_request::AgentStreamingRequest; +use crate::balancer_applicable_state_holder::BalancerApplicableStateHolder; use crate::buffered_request_manager::BufferedRequestManager; +use crate::cluster_serves_another_inference_mode::ClusterServesAnotherInferenceMode; use crate::continuation_decision::ContinuationDecision; +use crate::controls_session::ControlsSession as _; use crate::controls_websocket_endpoint::ControlsWebSocketEndpoint; use crate::inference_service::app_data::AppData; use crate::inference_service::configuration::Configuration as InferenceServiceConfiguration; @@ -45,6 +53,24 @@ use crate::request_from_agent::request_from_agent; use crate::respond_with_error::respond_with_error; use crate::websocket_session_controller::WebSocketSessionController; +async fn send_inference_mode_mismatch( + cluster_serves_another_inference_mode: ClusterServesAnotherInferenceMode, + request_id: String, + websocket_session_controller: &mut WebSocketSessionController, +) { + websocket_session_controller + .send_response_safe(OutgoingMessage::Response(ResponseEnvelope { + generated_by: None, + request_id, + response: OutgoingResponse::GeneratedToken( + GeneratedTokenResult::InferenceModeMismatch( + cluster_serves_another_inference_mode.to_string(), + ), + ), + })) + .await; +} + async fn handle_inference_request( connection_close: &CancellationToken, context: Arc, @@ -55,6 +81,20 @@ async fn handle_inference_request( TParams: AgentStreamingRequest + Debug + Send + 'static, TParams::Response: Debug + Into + StreamableResult, { + if let Err(cluster_serves_another_inference_mode) = context + .balancer_applicable_state_holder + .require_inference_mode(InferenceMode::TextGeneration) + { + send_inference_mode_mismatch( + cluster_serves_another_inference_mode, + request_id, + &mut websocket_session_controller, + ) + .await; + + return; + } + let request_close = connection_close.child_token(); let Some(request_registration) = RequestRegistryGuard::register( @@ -101,6 +141,7 @@ async fn respond( http_request: HttpRequest, ) -> Result { let inference_socket_controller = InferenceSocketController { + balancer_applicable_state_holder: app_data.balancer_applicable_state_holder.clone(), buffered_request_manager: app_data.buffered_request_manager.clone(), inference_service_configuration: app_data.inference_service_configuration.clone(), shutdown: app_data.shutdown.clone(), @@ -114,6 +155,7 @@ type InferenceJsonRpcMessage = InferenceServerMessage; type InferenceJsonRpcRequest = InferenceServerRequest; struct InferenceSocketController { + balancer_applicable_state_holder: Arc, buffered_request_manager: Arc, inference_service_configuration: InferenceServiceConfiguration, shutdown: CancellationToken, @@ -126,6 +168,7 @@ impl ControlsWebSocketEndpoint for InferenceSocketController { fn create_context(&self) -> Self::Context { InferenceSocketControllerContext { + balancer_applicable_state_holder: self.balancer_applicable_state_holder.clone(), buffered_request_manager: self.buffered_request_manager.clone(), inference_service_configuration: self.inference_service_configuration.clone(), request_cancellation_tokens: Arc::default(), @@ -232,10 +275,15 @@ impl ControlsWebSocketEndpoint for InferenceSocketController { } async fn on_connection_start( - _connection_close: CancellationToken, - _context: Arc, - _session: &mut Session, + connection_close: CancellationToken, + context: Arc, + session: &mut Session, ) { + spawn_cluster_inference_mode_watcher( + context.balancer_applicable_state_holder.clone(), + connection_close, + session.clone(), + ); } } diff --git a/paddler_balancer/src/inference_service/http_route/api/ws_inference_socket/spawn_cluster_inference_mode_watcher.rs b/paddler_balancer/src/inference_service/http_route/api/ws_inference_socket/spawn_cluster_inference_mode_watcher.rs new file mode 100644 index 000000000..4d1df3d21 --- /dev/null +++ b/paddler_balancer/src/inference_service/http_route/api/ws_inference_socket/spawn_cluster_inference_mode_watcher.rs @@ -0,0 +1,53 @@ +use std::sync::Arc; + +use actix_web::rt; +use actix_ws::Session; +use tokio::select; +use tokio_util::sync::CancellationToken; + +use paddler_messaging::inference_client::message::Message as OutgoingMessage; +use paddler_messaging::inference_client::notification::Notification; +use paddler_messaging::subscribes_to_updates::SubscribesToUpdates as _; + +use crate::balancer_applicable_state_holder::BalancerApplicableStateHolder; +use crate::controls_session::ControlsSession as _; +use crate::websocket_session_controller::WebSocketSessionController; + +pub fn spawn_cluster_inference_mode_watcher( + balancer_applicable_state_holder: Arc, + connection_close: CancellationToken, + session: Session, +) { + rt::spawn(async move { + let mut update_rx = balancer_applicable_state_holder.subscribe_to_updates(); + let mut session_controller = WebSocketSessionController::::new(session); + let mut last_inference_mode = balancer_applicable_state_holder.inference_mode(); + + session_controller + .send_response_safe(OutgoingMessage::Notification( + Notification::ClusterInferenceMode(last_inference_mode), + )) + .await; + + loop { + select! { + () = connection_close.cancelled() => break, + Ok(()) = update_rx.changed() => { + let current_inference_mode = balancer_applicable_state_holder.inference_mode(); + + if current_inference_mode == last_inference_mode { + continue; + } + + last_inference_mode = current_inference_mode; + + session_controller + .send_response_safe(OutgoingMessage::Notification( + Notification::ClusterInferenceMode(current_inference_mode), + )) + .await; + } + } + } + }); +} diff --git a/paddler_balancer/src/inference_service/inference_mode_routes.rs b/paddler_balancer/src/inference_service/inference_mode_routes.rs deleted file mode 100644 index 072724695..000000000 --- a/paddler_balancer/src/inference_service/inference_mode_routes.rs +++ /dev/null @@ -1,21 +0,0 @@ -use actix_web::web::ServiceConfig; - -use paddler_messaging::inference_mode::InferenceMode; - -use crate::inference_service::http_route::api::post_continue_from_conversation_history::post_continue_from_conversation_history; -use crate::inference_service::http_route::api::post_continue_from_raw_prompt::post_continue_from_raw_prompt; -use crate::inference_service::http_route::api::post_decide::post_decide; -use crate::inference_service::http_route::api::post_generate_embedding_batch::post_generate_embedding_batch; -use crate::inference_service::http_route::api::ws_inference_socket::ws_inference_socket; - -pub fn inference_mode_routes(inference_mode: InferenceMode, service_config: &mut ServiceConfig) { - match inference_mode { - InferenceMode::Decision => post_decide(service_config), - InferenceMode::Embeddings => post_generate_embedding_batch(service_config), - InferenceMode::TextGeneration => { - post_continue_from_conversation_history(service_config); - post_continue_from_raw_prompt(service_config); - ws_inference_socket(service_config); - } - } -} diff --git a/paddler_balancer/src/inference_service/mod.rs b/paddler_balancer/src/inference_service/mod.rs index 820e9af5a..ee34278c8 100644 --- a/paddler_balancer/src/inference_service/mod.rs +++ b/paddler_balancer/src/inference_service/mod.rs @@ -1,7 +1,6 @@ pub mod app_data; pub mod configuration; pub mod http_route; -pub mod inference_mode_routes; use std::net::SocketAddr; use std::sync::Arc; @@ -13,8 +12,6 @@ use async_trait::async_trait; use tokio_util::sync::CancellationToken; use trzcina::Service; -use paddler_messaging::inference_mode::InferenceMode; - use crate::agent_controller_pool::AgentControllerPool; use crate::balancer_applicable_state_holder::BalancerApplicableStateHolder; use crate::buffered_request_manager::BufferedRequestManager; @@ -24,7 +21,11 @@ use crate::http_listener::HttpListener; use crate::http_route::get_health::get_health; use crate::inference_service::app_data::AppData; use crate::inference_service::configuration::Configuration as InferenceServiceConfiguration; -use crate::inference_service::inference_mode_routes::inference_mode_routes; +use crate::inference_service::http_route::api::post_continue_from_conversation_history::post_continue_from_conversation_history; +use crate::inference_service::http_route::api::post_continue_from_raw_prompt::post_continue_from_raw_prompt; +use crate::inference_service::http_route::api::post_decide::post_decide; +use crate::inference_service::http_route::api::post_generate_embedding_batch::post_generate_embedding_batch; +use crate::inference_service::http_route::api::ws_inference_socket::ws_inference_socket; use crate::run_http_service::run_http_service; use crate::run_http_service_parameters::RunHttpServiceParameters; @@ -34,7 +35,6 @@ pub struct InferenceService { pub buffered_request_manager: Arc, pub configuration: InferenceServiceConfiguration, pub http_listener: HttpListener, - pub inference_mode: InferenceMode, pub web_admin_panel_addr: Option, } @@ -46,7 +46,6 @@ impl Service for InferenceService { async fn run(self: Box, shutdown: CancellationToken) -> Result<()> { let service_name = self.name(); - let inference_mode = self.inference_mode; let cors_allowed_hosts_arc = cors_allowed_hosts_with_web_admin_panel( &self.configuration.cors_allowed_hosts, self.web_admin_panel_addr, @@ -68,9 +67,11 @@ impl Service for InferenceService { .wrap(create_cors_middleware(&cors_allowed_hosts_arc)) .app_data(app_data.clone()) .configure(get_health) - .configure(move |service_config| { - inference_mode_routes(inference_mode, service_config); - }) + .configure(post_continue_from_conversation_history) + .configure(post_continue_from_raw_prompt) + .configure(post_decide) + .configure(post_generate_embedding_batch) + .configure(ws_inference_socket) }, http_listener: self.http_listener, service_name, diff --git a/paddler_balancer/src/lib.rs b/paddler_balancer/src/lib.rs index ef8eeb1d4..acd350edb 100644 --- a/paddler_balancer/src/lib.rs +++ b/paddler_balancer/src/lib.rs @@ -25,6 +25,7 @@ mod buffered_request_counter; pub mod buffered_request_manager; pub mod cancellation_token_stream_guard; pub mod chunk_forwarding_session_controller; +pub mod cluster_serves_another_inference_mode; pub mod compatibility; pub mod continuation_decision; pub mod controls_session; diff --git a/paddler_balancer/src/management_service/http_route/api/get_balancer_applicable_state.rs b/paddler_balancer/src/management_service/http_route/api/get_balancer_applicable_state.rs index 42f0016b3..bfe7fc418 100644 --- a/paddler_balancer/src/management_service/http_route/api/get_balancer_applicable_state.rs +++ b/paddler_balancer/src/management_service/http_route/api/get_balancer_applicable_state.rs @@ -39,7 +39,6 @@ mod tests { use paddler_messaging::agent_desired_state::AgentDesiredState; use paddler_messaging::api_path::ApiPath; use paddler_messaging::balancer_desired_state::BalancerDesiredState; - use paddler_messaging::inference_mode::InferenceMode; use paddler_state_database::memory::Memory; use super::get_balancer_applicable_state; @@ -53,17 +52,14 @@ mod tests { fn build_app_data( balancer_applicable_state_holder: Arc, ) -> Data { - let (balancer_desired_state_notify_tx, _balancer_desired_state_notify_rx) = watch::channel( - BalancerDesiredState::unconfigured(InferenceMode::TextGeneration), - ); + let (balancer_desired_state_notify_tx, _balancer_desired_state_notify_rx) = + watch::channel(BalancerDesiredState::default()); Data::new(AppData { - agent_controller_pool: Arc::new(AgentControllerPool::new( - InferenceMode::TextGeneration, - )), + agent_controller_pool: Arc::new(AgentControllerPool::default()), balancer_applicable_state_holder, buffered_request_manager: Arc::new(BufferedRequestManager::new( - Arc::new(AgentControllerPool::new(InferenceMode::TextGeneration)), + Arc::new(AgentControllerPool::default()), Duration::from_secs(1), 10, )), @@ -71,8 +67,7 @@ mod tests { shutdown: CancellationToken::new(), state_database: Arc::new(Memory::new( balancer_desired_state_notify_tx, - InferenceMode::TextGeneration, - BalancerDesiredState::unconfigured(InferenceMode::TextGeneration), + BalancerDesiredState::default(), )), statsd_prefix: "paddler".to_owned(), }) @@ -81,17 +76,13 @@ mod tests { #[actix_web::test] async fn responds_with_stored_agent_desired_state() { let balancer_applicable_state_holder = Arc::new(BalancerApplicableStateHolder::new( - BalancerApplicableState::from(BalancerDesiredState::unconfigured( - InferenceMode::TextGeneration, - )), + BalancerApplicableState::from(BalancerDesiredState::default()), )); balancer_applicable_state_holder.set_balancer_applicable_state(BalancerApplicableState { agent_desired_state: AgentDesiredState { model: AgentDesiredModel::LocalToAgent("model.gguf".to_owned()), - ..AgentDesiredState::from(BalancerDesiredState::unconfigured( - InferenceMode::TextGeneration, - )) + ..AgentDesiredState::from(BalancerDesiredState::default()) }, }); diff --git a/paddler_balancer/src/management_service/http_route/api/get_balancer_desired_state.rs b/paddler_balancer/src/management_service/http_route/api/get_balancer_desired_state.rs index 2c6985a97..b81f1e3f9 100644 --- a/paddler_balancer/src/management_service/http_route/api/get_balancer_desired_state.rs +++ b/paddler_balancer/src/management_service/http_route/api/get_balancer_desired_state.rs @@ -42,7 +42,6 @@ mod tests { use paddler_messaging::agent_desired_model::AgentDesiredModel; use paddler_messaging::api_path::ApiPath; use paddler_messaging::balancer_desired_state::BalancerDesiredState; - use paddler_messaging::inference_mode::InferenceMode; use paddler_state_database::file::File; use paddler_state_database::memory::Memory; use paddler_state_database::state_database::StateDatabase; @@ -57,16 +56,12 @@ mod tests { fn build_app_data(state_database: Arc) -> Data { Data::new(AppData { - agent_controller_pool: Arc::new(AgentControllerPool::new( - InferenceMode::TextGeneration, - )), + agent_controller_pool: Arc::new(AgentControllerPool::default()), balancer_applicable_state_holder: Arc::new(BalancerApplicableStateHolder::new( - BalancerApplicableState::from(BalancerDesiredState::unconfigured( - InferenceMode::TextGeneration, - )), + BalancerApplicableState::from(BalancerDesiredState::default()), )), buffered_request_manager: Arc::new(BufferedRequestManager::new( - Arc::new(AgentControllerPool::new(InferenceMode::TextGeneration)), + Arc::new(AgentControllerPool::default()), Duration::from_secs(1), 10, )), @@ -79,16 +74,14 @@ mod tests { #[actix_web::test] async fn responds_with_stored_desired_state() { - let (balancer_desired_state_notify_tx, _balancer_desired_state_notify_rx) = watch::channel( - BalancerDesiredState::unconfigured(InferenceMode::TextGeneration), - ); + let (balancer_desired_state_notify_tx, _balancer_desired_state_notify_rx) = + watch::channel(BalancerDesiredState::default()); let stored_state = BalancerDesiredState { model: AgentDesiredModel::LocalToAgent("model.gguf".to_owned()), - ..BalancerDesiredState::unconfigured(InferenceMode::TextGeneration) + ..BalancerDesiredState::default() }; let state_database = Arc::new(Memory::new( balancer_desired_state_notify_tx, - InferenceMode::TextGeneration, stored_state.clone(), )); let app_data = build_app_data(state_database); @@ -112,13 +105,11 @@ mod tests { #[actix_web::test] async fn responds_with_internal_server_error_when_reading_state_fails() { - let (balancer_desired_state_notify_tx, _balancer_desired_state_notify_rx) = watch::channel( - BalancerDesiredState::unconfigured(InferenceMode::TextGeneration), - ); + let (balancer_desired_state_notify_tx, _balancer_desired_state_notify_rx) = + watch::channel(BalancerDesiredState::default()); let temp_dir = TempDir::new().unwrap(); let state_database = Arc::new(File::new( balancer_desired_state_notify_tx, - InferenceMode::TextGeneration, temp_dir.path().to_path_buf(), )); let app_data = build_app_data(state_database); diff --git a/paddler_balancer/src/management_service/http_route/api/get_buffered_requests.rs b/paddler_balancer/src/management_service/http_route/api/get_buffered_requests.rs index 174b5cf78..2b0a5aa5f 100644 --- a/paddler_balancer/src/management_service/http_route/api/get_buffered_requests.rs +++ b/paddler_balancer/src/management_service/http_route/api/get_buffered_requests.rs @@ -33,7 +33,6 @@ mod tests { use paddler_messaging::api_path::ApiPath; use paddler_messaging::balancer_desired_state::BalancerDesiredState; use paddler_messaging::buffered_request_manager_snapshot::BufferedRequestManagerSnapshot; - use paddler_messaging::inference_mode::InferenceMode; use paddler_state_database::memory::Memory; use super::get_buffered_requests; @@ -47,7 +46,7 @@ mod tests { #[actix_web::test] async fn responds_with_current_buffered_request_count() { let buffered_request_manager = Arc::new(BufferedRequestManager::new( - Arc::new(AgentControllerPool::new(InferenceMode::TextGeneration)), + Arc::new(AgentControllerPool::default()), Duration::from_secs(1), 10, )); @@ -59,26 +58,20 @@ mod tests { .buffered_request_counter .try_admit(); - let (balancer_desired_state_notify_tx, _balancer_desired_state_notify_rx) = watch::channel( - BalancerDesiredState::unconfigured(InferenceMode::TextGeneration), - ); + let (balancer_desired_state_notify_tx, _balancer_desired_state_notify_rx) = + watch::channel(BalancerDesiredState::default()); let app_data = Data::new(AppData { - agent_controller_pool: Arc::new(AgentControllerPool::new( - InferenceMode::TextGeneration, - )), + agent_controller_pool: Arc::new(AgentControllerPool::default()), balancer_applicable_state_holder: Arc::new(BalancerApplicableStateHolder::new( - BalancerApplicableState::from(BalancerDesiredState::unconfigured( - InferenceMode::TextGeneration, - )), + BalancerApplicableState::from(BalancerDesiredState::default()), )), buffered_request_manager, agent_response_senders: AgentResponseSenders::default(), shutdown: CancellationToken::new(), state_database: Arc::new(Memory::new( balancer_desired_state_notify_tx, - InferenceMode::TextGeneration, - BalancerDesiredState::unconfigured(InferenceMode::TextGeneration), + BalancerDesiredState::default(), )), statsd_prefix: "paddler".to_owned(), }); diff --git a/paddler_balancer/src/management_service/http_route/api/put_balancer_desired_state.rs b/paddler_balancer/src/management_service/http_route/api/put_balancer_desired_state.rs index 27a4fec69..1e40ca465 100644 --- a/paddler_balancer/src/management_service/http_route/api/put_balancer_desired_state.rs +++ b/paddler_balancer/src/management_service/http_route/api/put_balancer_desired_state.rs @@ -2,14 +2,12 @@ use actix_web::Error; use actix_web::HttpResponse; use actix_web::Responder; use actix_web::error::ErrorInternalServerError; -use actix_web::error::ErrorUnprocessableEntity; use actix_web::web; use actix_web::web::put; use paddler_messaging::api_path::ApiPath; use paddler_messaging::balancer_desired_state::BalancerDesiredState; use paddler_messaging::subscribes_to_updates::SubscribesToUpdates as _; -use paddler_state_database::state_database_error::StateDatabaseError; use crate::management_service::app_data::AppData; @@ -25,12 +23,7 @@ async fn respond( .state_database .store_balancer_desired_state(&balancer_desired_state.into_inner()) .await - .map_err(|state_database_error| match state_database_error { - StateDatabaseError::RequestedStateServesAnotherMode { .. } => { - ErrorUnprocessableEntity(state_database_error) - } - unstorable_state => ErrorInternalServerError(unstorable_state), - })?; + .map_err(ErrorInternalServerError)?; applied_state_rx .changed() @@ -66,7 +59,6 @@ mod tests { use paddler_messaging::agent_desired_model::AgentDesiredModel; use paddler_messaging::api_path::ApiPath; use paddler_messaging::balancer_desired_state::BalancerDesiredState; - use paddler_messaging::inference_mode::InferenceMode; use paddler_state_database::file::File; use paddler_state_database::memory::Memory; use paddler_state_database::state_database::StateDatabase; @@ -82,16 +74,12 @@ mod tests { fn build_app_data(state_database: Arc) -> Data { Data::new(AppData { - agent_controller_pool: Arc::new(AgentControllerPool::new( - InferenceMode::TextGeneration, - )), + agent_controller_pool: Arc::new(AgentControllerPool::default()), balancer_applicable_state_holder: Arc::new(BalancerApplicableStateHolder::new( - BalancerApplicableState::from(BalancerDesiredState::unconfigured( - InferenceMode::TextGeneration, - )), + BalancerApplicableState::from(BalancerDesiredState::default()), )), buffered_request_manager: Arc::new(BufferedRequestManager::new( - Arc::new(AgentControllerPool::new(InferenceMode::TextGeneration)), + Arc::new(AgentControllerPool::default()), Duration::from_secs(1), 10, )), @@ -104,13 +92,11 @@ mod tests { #[actix_web::test] async fn responds_once_the_stored_state_is_applicable() { - let (balancer_desired_state_notify_tx, balancer_desired_state_rx) = watch::channel( - BalancerDesiredState::unconfigured(InferenceMode::TextGeneration), - ); + let (balancer_desired_state_notify_tx, balancer_desired_state_rx) = + watch::channel(BalancerDesiredState::default()); let app_data = build_app_data(Arc::new(Memory::new( balancer_desired_state_notify_tx, - InferenceMode::TextGeneration, - BalancerDesiredState::unconfigured(InferenceMode::TextGeneration), + BalancerDesiredState::default(), ))); let reconciliation_shutdown = CancellationToken::new(); let reconciliation = spawn( @@ -132,7 +118,7 @@ mod tests { .uri(ApiPath::BALANCER_DESIRED_STATE) .set_json(BalancerDesiredState { model: applied_model.clone(), - ..BalancerDesiredState::unconfigured(InferenceMode::TextGeneration) + ..BalancerDesiredState::default() }) .to_request(); @@ -154,13 +140,11 @@ mod tests { #[actix_web::test] async fn responds_with_bad_request_when_model_runtime_parameters_are_invalid() { - let (balancer_desired_state_notify_tx, balancer_desired_state_notify_rx) = watch::channel( - BalancerDesiredState::unconfigured(InferenceMode::TextGeneration), - ); + let (balancer_desired_state_notify_tx, balancer_desired_state_notify_rx) = + watch::channel(BalancerDesiredState::default()); let state_database = Arc::new(Memory::new( balancer_desired_state_notify_tx, - InferenceMode::TextGeneration, - BalancerDesiredState::unconfigured(InferenceMode::TextGeneration), + BalancerDesiredState::default(), )); let app_data = build_app_data(state_database); let app = init_service( @@ -169,10 +153,7 @@ mod tests { .configure(put_balancer_desired_state), ) .await; - let mut invalid_desired_state = to_value(BalancerDesiredState::unconfigured( - InferenceMode::TextGeneration, - )) - .unwrap(); + let mut invalid_desired_state = to_value(BalancerDesiredState::default()).unwrap(); invalid_desired_state["model_runtime_parameters"]["n_gpu_layers"] = json!(-2); @@ -189,14 +170,12 @@ mod tests { #[actix_web::test] async fn responds_with_internal_server_error_when_store_fails() { - let (balancer_desired_state_notify_tx, _balancer_desired_state_notify_rx) = watch::channel( - BalancerDesiredState::unconfigured(InferenceMode::TextGeneration), - ); + let (balancer_desired_state_notify_tx, _balancer_desired_state_notify_rx) = + watch::channel(BalancerDesiredState::default()); let directory_in_place_of_the_state_file = TempDir::new().expect("a temporary directory must be creatable"); let app_data = build_app_data(Arc::new(File::new( balancer_desired_state_notify_tx, - InferenceMode::TextGeneration, directory_in_place_of_the_state_file.path().to_path_buf(), ))); let app = init_service( @@ -207,41 +186,10 @@ mod tests { .await; let request = TestRequest::put() .uri(ApiPath::BALANCER_DESIRED_STATE) - .set_json(BalancerDesiredState::unconfigured( - InferenceMode::TextGeneration, - )) + .set_json(BalancerDesiredState::default()) .to_request(); let response = call_service(&app, request).await; assert_eq!(response.status(), StatusCode::INTERNAL_SERVER_ERROR); } - - #[actix_web::test] - async fn responds_with_unprocessable_entity_when_the_state_serves_another_mode() { - let (balancer_desired_state_notify_tx, _balancer_desired_state_notify_rx) = watch::channel( - BalancerDesiredState::unconfigured(InferenceMode::TextGeneration), - ); - let app_data = build_app_data(Arc::new(Memory::new( - balancer_desired_state_notify_tx, - InferenceMode::TextGeneration, - BalancerDesiredState::unconfigured(InferenceMode::TextGeneration), - ))); - let app = init_service( - App::new() - .app_data(app_data) - .configure(put_balancer_desired_state), - ) - .await; - let request = TestRequest::put() - .uri(ApiPath::BALANCER_DESIRED_STATE) - .set_json(BalancerDesiredState::unconfigured( - InferenceMode::Embeddings, - )) - .to_request(); - - assert_eq!( - call_service(&app, request).await.status(), - StatusCode::UNPROCESSABLE_ENTITY - ); - } } diff --git a/paddler_balancer/src/reconciliation_service.rs b/paddler_balancer/src/reconciliation_service.rs index 38543cd88..048a4f2cf 100644 --- a/paddler_balancer/src/reconciliation_service.rs +++ b/paddler_balancer/src/reconciliation_service.rs @@ -64,7 +64,6 @@ mod tests { use trzcina::Service as _; use paddler_messaging::balancer_desired_state::BalancerDesiredState; - use paddler_messaging::inference_mode::InferenceMode; use super::ReconciliationService; use crate::agent_controller_pool::AgentControllerPool; @@ -76,22 +75,17 @@ mod tests { #[tokio::test] async fn stops_cleanly_when_its_state_database_closes_during_shutdown() { for _ in 0..RUNS_TO_EXERCISE_EVERY_BRANCH_ORDER { - let (balancer_desired_state_tx, balancer_desired_state_rx) = watch::channel( - BalancerDesiredState::unconfigured(InferenceMode::TextGeneration), - ); + let (balancer_desired_state_tx, balancer_desired_state_rx) = + watch::channel(BalancerDesiredState::default()); let shutdown = CancellationToken::new(); shutdown.cancel(); drop(balancer_desired_state_tx); Box::new(ReconciliationService { - agent_controller_pool: Arc::new(AgentControllerPool::new( - InferenceMode::TextGeneration, - )), + agent_controller_pool: Arc::new(AgentControllerPool::default()), balancer_applicable_state_holder: Arc::new(BalancerApplicableStateHolder::new( - BalancerApplicableState::from(BalancerDesiredState::unconfigured( - InferenceMode::TextGeneration, - )), + BalancerApplicableState::from(BalancerDesiredState::default()), )), balancer_desired_state_rx, }) diff --git a/paddler_balancer/src/snapshots_stream.rs b/paddler_balancer/src/snapshots_stream.rs index ba95f36ab..c56f4cb68 100644 --- a/paddler_balancer/src/snapshots_stream.rs +++ b/paddler_balancer/src/snapshots_stream.rs @@ -39,7 +39,6 @@ mod tests { use tokio_util::sync::CancellationToken; use paddler_messaging::buffered_request_manager_snapshot::BufferedRequestManagerSnapshot; - use paddler_messaging::inference_mode::InferenceMode; use super::snapshots_stream; use crate::agent_controller_pool::AgentControllerPool; @@ -47,7 +46,7 @@ mod tests { fn buffered_request_manager() -> Arc { Arc::new(BufferedRequestManager::new( - Arc::new(AgentControllerPool::new(InferenceMode::TextGeneration)), + Arc::new(AgentControllerPool::default()), Duration::MAX, 1, )) diff --git a/paddler_balancer/src/unbounded_stream_from_agent.rs b/paddler_balancer/src/unbounded_stream_from_agent.rs index dd0626cf0..726d9baa4 100644 --- a/paddler_balancer/src/unbounded_stream_from_agent.rs +++ b/paddler_balancer/src/unbounded_stream_from_agent.rs @@ -69,7 +69,6 @@ mod tests { use tokio_util::sync::CancellationToken; use paddler_messaging::inference_client::message::Message as OutgoingMessage; - use paddler_messaging::inference_mode::InferenceMode; use paddler_messaging::jsonrpc::error::Error as JsonRpcError; use paddler_messaging::jsonrpc::error_envelope::ErrorEnvelope; use paddler_messaging::request_params::continue_from_raw_prompt_params::ContinueFromRawPromptParams; @@ -95,7 +94,7 @@ mod tests { #[actix_web::test] async fn spawned_task_runs_request_from_agent_and_closes_stream_on_shutdown() { - let pool = Arc::new(AgentControllerPool::new(InferenceMode::TextGeneration)); + let pool = Arc::new(AgentControllerPool::default()); let buffered_request_manager = Arc::new(BufferedRequestManager::new( pool, Duration::from_secs(1), diff --git a/paddler_balancer/src/web_admin_panel_service/mod.rs b/paddler_balancer/src/web_admin_panel_service/mod.rs index 9cb4b1c3e..fd0ab6aeb 100644 --- a/paddler_balancer/src/web_admin_panel_service/mod.rs +++ b/paddler_balancer/src/web_admin_panel_service/mod.rs @@ -77,7 +77,6 @@ mod tests { use super::WebAdminPanelService; use crate::http_listener::HttpListener; - use paddler_messaging::inference_mode::InferenceMode; use crate::resolved_socket_addr::ResolvedSocketAddr; use crate::web_admin_panel_service::template_data::TemplateData; @@ -93,7 +92,6 @@ mod tests { compat_openai_addr: None, compat_typesafe_addr: None, inference_addr: ResolvedSocketAddr::from(ephemeral_loopback_addr), - inference_mode: InferenceMode::TextGeneration, management_addr: ResolvedSocketAddr::from(ephemeral_loopback_addr), max_buffered_requests: 32, statsd_prefix: "paddler".to_owned(), diff --git a/paddler_balancer/src/web_admin_panel_service/template_data.rs b/paddler_balancer/src/web_admin_panel_service/template_data.rs index 6d155f330..4e392fd98 100644 --- a/paddler_balancer/src/web_admin_panel_service/template_data.rs +++ b/paddler_balancer/src/web_admin_panel_service/template_data.rs @@ -1,7 +1,5 @@ use std::time::Duration; -use paddler_messaging::inference_mode::InferenceMode; - use crate::resolved_socket_addr::ResolvedSocketAddr; use crate::statsd_service::configuration::Configuration as StatsdServiceConfiguration; @@ -11,58 +9,8 @@ pub struct TemplateData { pub compat_openai_addr: Option, pub compat_typesafe_addr: Option, pub inference_addr: ResolvedSocketAddr, - pub inference_mode: InferenceMode, pub management_addr: ResolvedSocketAddr, pub max_buffered_requests: u64, pub statsd_prefix: String, pub statsd_service_configuration: Option, } - -impl TemplateData { - #[must_use] - pub const fn inference_mode_name(&self) -> &'static str { - match self.inference_mode { - InferenceMode::Decision => "Decision", - InferenceMode::Embeddings => "Embeddings", - InferenceMode::TextGeneration => "TextGeneration", - } - } -} - -#[cfg(test)] -mod tests { - use std::net::Ipv4Addr; - use std::net::SocketAddr; - use std::time::Duration; - - use paddler_messaging::inference_mode::InferenceMode; - - use super::TemplateData; - use crate::resolved_socket_addr::ResolvedSocketAddr; - - #[test] - fn names_the_inference_mode_the_balancer_serves() { - let loopback = ResolvedSocketAddr::from(SocketAddr::from((Ipv4Addr::LOCALHOST, 0))); - - assert_eq!( - [ - InferenceMode::Decision, - InferenceMode::Embeddings, - InferenceMode::TextGeneration, - ] - .map(|inference_mode| TemplateData { - buffered_request_timeout: Duration::from_secs(1), - compat_openai_addr: None, - compat_typesafe_addr: None, - inference_addr: loopback.clone(), - inference_mode, - management_addr: loopback.clone(), - max_buffered_requests: 1, - statsd_prefix: "paddler_".to_owned(), - statsd_service_configuration: None, - } - .inference_mode_name()), - ["Decision", "Embeddings", "TextGeneration"] - ); - } -} diff --git a/paddler_balancer/templates/web_admin_panel.html b/paddler_balancer/templates/web_admin_panel.html index 261ec3de1..655412fe4 100644 --- a/paddler_balancer/templates/web_admin_panel.html +++ b/paddler_balancer/templates/web_admin_panel.html @@ -22,7 +22,6 @@ data-compat-typesafe-addr="{{ compat_typesafe_addr.input_addr }}" {% endif %} data-inference-addr="{{ template_data.inference_addr.input_addr }}" - data-inference-mode="{{ template_data.inference_mode_name() }}" data-management-addr="{{ template_data.management_addr.input_addr }}" data-max-buffered-requests="{{ template_data.max_buffered_requests }}" {% if let Some(statsd_service_configuration) = template_data.statsd_service_configuration %} diff --git a/paddler_balancer/tests/openai_chat_completion_reports_an_agent_whose_socket_closed_after_dispatch_as_a_bad_gateway.rs b/paddler_balancer/tests/openai_chat_completion_reports_an_agent_whose_socket_closed_after_dispatch_as_a_bad_gateway.rs index fdfa051d2..021c5c27d 100644 --- a/paddler_balancer/tests/openai_chat_completion_reports_an_agent_whose_socket_closed_after_dispatch_as_a_bad_gateway.rs +++ b/paddler_balancer/tests/openai_chat_completion_reports_an_agent_whose_socket_closed_after_dispatch_as_a_bad_gateway.rs @@ -40,7 +40,7 @@ const LONGER_THAN_THE_TEST: Duration = Duration::from_hours(1); #[actix_web::test] async fn openai_chat_completion_reports_an_agent_whose_socket_closed_after_dispatch_as_a_bad_gateway() { - let agent_controller_pool = Arc::new(AgentControllerPool::new(InferenceMode::TextGeneration)); + let agent_controller_pool = Arc::new(AgentControllerPool::default()); let (agent_message_tx, agent_message_rx) = mpsc::unbounded_channel(); drop(agent_message_rx); @@ -68,9 +68,7 @@ async fn openai_chat_completion_reports_an_agent_whose_socket_closed_after_dispa App::new() .app_data(Data::new(CompatibilityAppData { balancer_applicable_state_holder: Arc::new(BalancerApplicableStateHolder::new( - BalancerApplicableState::from(BalancerDesiredState::unconfigured( - InferenceMode::TextGeneration, - )), + BalancerApplicableState::from(BalancerDesiredState::default()), )), buffered_request_manager: Arc::new(BufferedRequestManager::new( agent_controller_pool, diff --git a/paddler_balancer_runner/src/balancer_runner_config.rs b/paddler_balancer_runner/src/balancer_runner_config.rs index 88e9ed4fc..95fab5b72 100644 --- a/paddler_balancer_runner/src/balancer_runner_config.rs +++ b/paddler_balancer_runner/src/balancer_runner_config.rs @@ -1,5 +1,6 @@ use std::time::Duration; +use paddler_balancer::compatibility::compatibility_service_configuration::CompatibilityServiceConfiguration; use paddler_balancer::inference_service::configuration::Configuration as InferenceServiceConfiguration; use paddler_balancer::management_service::configuration::Configuration as ManagementServiceConfiguration; use paddler_balancer::statsd_service::configuration::Configuration as StatsdServiceConfiguration; @@ -7,17 +8,16 @@ use paddler_balancer::statsd_service::configuration::Configuration as StatsdServ use paddler_balancer::web_admin_panel_service::configuration::Configuration as WebAdminPanelServiceConfiguration; use paddler_state_database::state_database_type::StateDatabaseType; -use crate::balancer_serving_mode::BalancerServingMode; - pub struct BalancerRunnerConfig { pub buffered_request_timeout: Duration, pub inference_service_configuration: InferenceServiceConfiguration, pub management_service_configuration: ManagementServiceConfiguration, pub max_buffered_requests: u64, - pub serving_mode: BalancerServingMode, + pub openai_service_configuration: Option, pub state_database_type: StateDatabaseType, pub statsd_prefix: String, pub statsd_service_configuration: Option, + pub typesafe_service_configuration: Option, #[cfg(feature = "web_admin_panel")] pub web_admin_panel_service_configuration: Option, } diff --git a/paddler_balancer_runner/src/balancer_service_bundle.rs b/paddler_balancer_runner/src/balancer_service_bundle.rs index 3cd22ba8c..aeb40945f 100644 --- a/paddler_balancer_runner/src/balancer_service_bundle.rs +++ b/paddler_balancer_runner/src/balancer_service_bundle.rs @@ -60,10 +60,11 @@ impl BalancerServiceBundle { inference_service_configuration, management_service_configuration, max_buffered_requests, - serving_mode, + openai_service_configuration, state_database_type, statsd_prefix, statsd_service_configuration, + typesafe_service_configuration, #[cfg(feature = "web_admin_panel")] web_admin_panel_service_configuration, }: BalancerRunnerConfig, @@ -79,32 +80,21 @@ impl BalancerServiceBundle { return Err(BalancerRunnerError::StatsdReportingIntervalIsZero); } - let inference_mode = serving_mode.inference_mode(); - let openai_service_configuration = serving_mode.openai_service_configuration(); - let typesafe_service_configuration = serving_mode.typesafe_service_configuration(); let (balancer_desired_state_tx, _initial_desired_state_rx) = - watch::channel(BalancerDesiredState::unconfigured(inference_mode)); + watch::channel(BalancerDesiredState::default()); - let agent_controller_pool = Arc::new(AgentControllerPool::new(inference_mode)); + let agent_controller_pool = Arc::new(AgentControllerPool::default()); let buffered_request_manager = Arc::new(BufferedRequestManager::new( agent_controller_pool.clone(), buffered_request_timeout, max_buffered_requests, )); let state_database: Arc = match state_database_type { - StateDatabaseType::File(path) => Arc::new(File::new( - balancer_desired_state_tx.clone(), - inference_mode, - path, - )), - StateDatabaseType::Memory => Arc::new(Memory::new( - balancer_desired_state_tx.clone(), - inference_mode, - BalancerDesiredState::unconfigured(inference_mode), - )), - StateDatabaseType::MemoryStartingWith(initial_desired_state) => Arc::new(Memory::new( + StateDatabaseType::File(path) => { + Arc::new(File::new(balancer_desired_state_tx.clone(), path)) + } + StateDatabaseType::Memory(initial_desired_state) => Arc::new(Memory::new( balancer_desired_state_tx.clone(), - inference_mode, *initial_desired_state, )), }; @@ -173,7 +163,6 @@ impl BalancerServiceBundle { compat_typesafe_addr: typesafe_service_configuration .map(|CompatibilityServiceConfiguration { addr }| addr), inference_addr: inference_service_configuration.addr.clone(), - inference_mode, management_addr: management_service_configuration.addr.clone(), max_buffered_requests, statsd_prefix: statsd_prefix.clone(), @@ -208,7 +197,6 @@ impl BalancerServiceBundle { buffered_request_manager: buffered_request_manager.clone(), configuration: inference_service_configuration.clone(), http_listener: inference_http_listener, - inference_mode, web_admin_panel_addr, }; @@ -319,12 +307,11 @@ mod tests { use super::BalancerServiceBundle; use crate::balancer_runner_config::BalancerRunnerConfig; - use crate::balancer_serving_mode::BalancerServingMode; #[cfg(feature = "web_admin_panel")] - const EXPECTED_SERVICE_COUNT: usize = 6; + const EXPECTED_SERVICE_COUNT: usize = 7; #[cfg(not(feature = "web_admin_panel"))] - const EXPECTED_SERVICE_COUNT: usize = 5; + const EXPECTED_SERVICE_COUNT: usize = 6; fn fully_configured_runner_config() -> BalancerRunnerConfig { BalancerRunnerConfig { @@ -339,17 +326,18 @@ mod tests { cors_allowed_hosts: vec![], }, max_buffered_requests: 30, - serving_mode: BalancerServingMode::TextGeneration { - openai_service_configuration: Some(CompatibilityServiceConfiguration { - addr: ResolvedSocketAddr::from(EPHEMERAL_LOOPBACK_ADDR), - }), - }, - state_database_type: StateDatabaseType::Memory, + openai_service_configuration: Some(CompatibilityServiceConfiguration { + addr: ResolvedSocketAddr::from(EPHEMERAL_LOOPBACK_ADDR), + }), + state_database_type: StateDatabaseType::Memory(Box::default()), statsd_prefix: "paddler_balancer_runner_test_".to_owned(), statsd_service_configuration: Some(StatsdServiceConfiguration { statsd_addr: ResolvedSocketAddr::from(EPHEMERAL_LOOPBACK_ADDR), statsd_reporting_interval: Duration::from_secs(10), }), + typesafe_service_configuration: Some(CompatibilityServiceConfiguration { + addr: ResolvedSocketAddr::from(EPHEMERAL_LOOPBACK_ADDR), + }), #[cfg(feature = "web_admin_panel")] web_admin_panel_service_configuration: Some(WebAdminPanelServiceConfiguration { addr: EPHEMERAL_LOOPBACK_ADDR, @@ -382,6 +370,10 @@ mod tests { .addresses .compat_openai .map(|compat_openai| compat_openai.port()), + bundle + .addresses + .compat_typesafe + .map(|compat_typesafe| compat_typesafe.port()), Some(bundle.addresses.inference.port()), Some(bundle.addresses.management.port()), #[cfg(feature = "web_admin_panel")] @@ -397,25 +389,4 @@ mod tests { .all(|bound_port| bound_port.is_some_and(|port| port != 0)) ); } - - #[tokio::test] - async fn reports_the_bound_address_of_the_typesafe_service() { - let bundle = BalancerServiceBundle::new(BalancerRunnerConfig { - serving_mode: BalancerServingMode::Decision { - typesafe_service_configuration: Some(CompatibilityServiceConfiguration { - addr: ResolvedSocketAddr::from(EPHEMERAL_LOOPBACK_ADDR), - }), - }, - ..fully_configured_runner_config() - }) - .await - .expect("a decision bundle must bind its ephemeral addresses"); - - assert!( - bundle - .addresses - .compat_typesafe - .is_some_and(|compat_typesafe| compat_typesafe.port() != 0) - ); - } } diff --git a/paddler_balancer_runner/src/balancer_serving_mode.rs b/paddler_balancer_runner/src/balancer_serving_mode.rs deleted file mode 100644 index 932f7ac89..000000000 --- a/paddler_balancer_runner/src/balancer_serving_mode.rs +++ /dev/null @@ -1,44 +0,0 @@ -use paddler_balancer::compatibility::compatibility_service_configuration::CompatibilityServiceConfiguration; -use paddler_messaging::inference_mode::InferenceMode; - -#[derive(Clone)] -pub enum BalancerServingMode { - Decision { - typesafe_service_configuration: Option, - }, - Embeddings, - TextGeneration { - openai_service_configuration: Option, - }, -} - -impl BalancerServingMode { - #[must_use] - pub const fn inference_mode(&self) -> InferenceMode { - match self { - Self::Decision { .. } => InferenceMode::Decision, - Self::Embeddings => InferenceMode::Embeddings, - Self::TextGeneration { .. } => InferenceMode::TextGeneration, - } - } - - #[must_use] - pub fn openai_service_configuration(&self) -> Option { - match self { - Self::Decision { .. } | Self::Embeddings => None, - Self::TextGeneration { - openai_service_configuration, - } => openai_service_configuration.clone(), - } - } - - #[must_use] - pub fn typesafe_service_configuration(&self) -> Option { - match self { - Self::Decision { - typesafe_service_configuration, - } => typesafe_service_configuration.clone(), - Self::Embeddings | Self::TextGeneration { .. } => None, - } - } -} diff --git a/paddler_balancer_runner/src/lib.rs b/paddler_balancer_runner/src/lib.rs index f1b114f7b..d4aae7dc6 100644 --- a/paddler_balancer_runner/src/lib.rs +++ b/paddler_balancer_runner/src/lib.rs @@ -4,4 +4,3 @@ pub mod balancer_runner_config; pub mod balancer_runner_error; pub mod balancer_runner_params; pub mod balancer_service_bundle; -pub mod balancer_serving_mode; diff --git a/paddler_balancer_runner/tests/balancer_runner_fails_to_start_when_an_address_is_taken.rs b/paddler_balancer_runner/tests/balancer_runner_fails_to_start_when_an_address_is_taken.rs index be2d3fc13..f7888d533 100644 --- a/paddler_balancer_runner/tests/balancer_runner_fails_to_start_when_an_address_is_taken.rs +++ b/paddler_balancer_runner/tests/balancer_runner_fails_to_start_when_an_address_is_taken.rs @@ -9,7 +9,6 @@ use paddler_balancer::web_admin_panel_service::configuration::Configuration as W use paddler_balancer_runner::balancer_runner::BalancerRunner; use paddler_balancer_runner::balancer_runner_error::BalancerRunnerError; use paddler_balancer_runner::balancer_runner_params::BalancerRunnerParams; -use paddler_balancer_runner::balancer_serving_mode::BalancerServingMode; use paddler_test_cluster_harness::ephemeral_loopback_addr::EPHEMERAL_LOOPBACK_ADDR; use crate::ephemeral_balancer_runner_params::ephemeral_balancer_runner_params; @@ -63,11 +62,9 @@ async fn balancer_runner_fails_to_start_when_its_compat_openai_address_is_taken( .expect("a bound listener must report its address"); let mut params = ephemeral_balancer_runner_params(CancellationToken::new()); - params.runner_config.serving_mode = BalancerServingMode::TextGeneration { - openai_service_configuration: Some(CompatibilityServiceConfiguration { - addr: ResolvedSocketAddr::from(taken_addr), - }), - }; + params.runner_config.openai_service_configuration = Some(CompatibilityServiceConfiguration { + addr: ResolvedSocketAddr::from(taken_addr), + }); assert!(matches!( start_error_of(params).await, @@ -84,11 +81,9 @@ async fn balancer_runner_fails_to_start_when_its_compat_typesafe_address_is_take .expect("a bound listener must report its address"); let mut params = ephemeral_balancer_runner_params(CancellationToken::new()); - params.runner_config.serving_mode = BalancerServingMode::Decision { - typesafe_service_configuration: Some(CompatibilityServiceConfiguration { - addr: ResolvedSocketAddr::from(taken_addr), - }), - }; + params.runner_config.typesafe_service_configuration = Some(CompatibilityServiceConfiguration { + addr: ResolvedSocketAddr::from(taken_addr), + }); assert!(matches!( start_error_of(params).await, diff --git a/paddler_balancer_runner/tests/balancer_runner_preserves_the_persisted_desired_state.rs b/paddler_balancer_runner/tests/balancer_runner_preserves_the_persisted_desired_state.rs index 50617c522..7882eb5f5 100644 --- a/paddler_balancer_runner/tests/balancer_runner_preserves_the_persisted_desired_state.rs +++ b/paddler_balancer_runner/tests/balancer_runner_preserves_the_persisted_desired_state.rs @@ -5,10 +5,8 @@ use tokio_util::sync::CancellationToken; use paddler_balancer_runner::balancer_runner::BalancerRunner; use paddler_messaging::agent_desired_model::AgentDesiredModel; use paddler_messaging::balancer_desired_state::BalancerDesiredState; -use paddler_messaging::balancer_inference_settings::BalancerInferenceSettings; use paddler_messaging::balancer_text_generation_settings::BalancerTextGenerationSettings; use paddler_messaging::chat_template::ChatTemplate; -use paddler_messaging::inference_mode::InferenceMode; use paddler_state_database::file::File as StateDatabaseFile; use paddler_state_database::state_database::StateDatabase as _; use paddler_state_database::state_database_type::StateDatabaseType; @@ -21,26 +19,20 @@ async fn balancer_runner_preserves_the_persisted_desired_state() { TempDir::new().expect("a temporary state database directory must be creatable"); let state_database_path = state_database_directory.path().join("state.json"); let persisted_state = BalancerDesiredState { - inference_settings: BalancerInferenceSettings::TextGeneration( - BalancerTextGenerationSettings { - chat_template_override: Some(ChatTemplate { - content: "persisted-chat-template".to_owned(), - }), - use_chat_template_override: true, - ..BalancerTextGenerationSettings::default() - }, - ), model: AgentDesiredModel::LocalToAgent("persisted-model".to_owned()), - ..BalancerDesiredState::unconfigured(InferenceMode::TextGeneration) + text_generation: BalancerTextGenerationSettings { + chat_template_override: Some(ChatTemplate { + content: "persisted-chat-template".to_owned(), + }), + use_chat_template_override: true, + ..BalancerTextGenerationSettings::default() + }, + ..BalancerDesiredState::default() }; - let (balancer_desired_state_tx, _balancer_desired_state_rx) = watch::channel( - BalancerDesiredState::unconfigured(InferenceMode::TextGeneration), - ); - let state_database = StateDatabaseFile::new( - balancer_desired_state_tx, - InferenceMode::TextGeneration, - state_database_path.clone(), - ); + let (balancer_desired_state_tx, _balancer_desired_state_rx) = + watch::channel(BalancerDesiredState::default()); + let state_database = + StateDatabaseFile::new(balancer_desired_state_tx, state_database_path.clone()); state_database .store_balancer_desired_state(&persisted_state) diff --git a/paddler_balancer_runner/tests/balancer_runner_refuses_a_stored_state_of_another_mode.rs b/paddler_balancer_runner/tests/balancer_runner_refuses_a_stored_state_of_another_mode.rs deleted file mode 100644 index f55f9e586..000000000 --- a/paddler_balancer_runner/tests/balancer_runner_refuses_a_stored_state_of_another_mode.rs +++ /dev/null @@ -1,57 +0,0 @@ -use tempfile::TempDir; -use tokio::sync::watch; -use tokio_util::sync::CancellationToken; - -use paddler_balancer_runner::balancer_runner::BalancerRunner; -use paddler_balancer_runner::balancer_runner_error::BalancerRunnerError; -use paddler_balancer_runner::balancer_serving_mode::BalancerServingMode; -use paddler_messaging::balancer_desired_state::BalancerDesiredState; -use paddler_messaging::inference_mode::InferenceMode; -use paddler_state_database::file::File as StateDatabaseFile; -use paddler_state_database::state_database::StateDatabase as _; -use paddler_state_database::state_database_error::StateDatabaseError; -use paddler_state_database::state_database_type::StateDatabaseType; - -use crate::ephemeral_balancer_runner_params::ephemeral_balancer_runner_params; - -#[tokio::test] -async fn balancer_runner_refuses_a_stored_state_of_another_mode() { - let state_database_directory = - TempDir::new().expect("a temporary state database directory must be creatable"); - let state_database_path = state_database_directory.path().join("state.json"); - let (balancer_desired_state_tx, _balancer_desired_state_rx) = watch::channel( - BalancerDesiredState::unconfigured(InferenceMode::Embeddings), - ); - - StateDatabaseFile::new( - balancer_desired_state_tx, - InferenceMode::Embeddings, - state_database_path.clone(), - ) - .read_balancer_desired_state() - .await - .expect("an embeddings state database must be created"); - - let mut params = ephemeral_balancer_runner_params(CancellationToken::new()); - - params.runner_config.serving_mode = BalancerServingMode::TextGeneration { - openai_service_configuration: None, - }; - params.runner_config.state_database_type = StateDatabaseType::File(state_database_path); - - let start_error = BalancerRunner::start(params) - .await - .err() - .expect("a text generation runner must not start from an embeddings state database"); - - assert!(matches!( - start_error, - BalancerRunnerError::StateDatabaseReadFailed { - source: StateDatabaseError::StoredStateServesAnotherMode { - cluster_inference_mode, - stored_inference_mode, - } - } if cluster_inference_mode == InferenceMode::TextGeneration - && stored_inference_mode == InferenceMode::Embeddings - )); -} diff --git a/paddler_balancer_runner/tests/balancer_runner_serves_the_desired_state_it_starts_with.rs b/paddler_balancer_runner/tests/balancer_runner_serves_the_desired_state_it_starts_with.rs index 103dc9655..bf89092c4 100644 --- a/paddler_balancer_runner/tests/balancer_runner_serves_the_desired_state_it_starts_with.rs +++ b/paddler_balancer_runner/tests/balancer_runner_serves_the_desired_state_it_starts_with.rs @@ -1,7 +1,6 @@ use tokio_util::sync::CancellationToken; use paddler_balancer_runner::balancer_runner::BalancerRunner; -use paddler_balancer_runner::balancer_serving_mode::BalancerServingMode; use paddler_messaging::agent_desired_model::AgentDesiredModel; use paddler_messaging::balancer_desired_state::BalancerDesiredState; use paddler_messaging::inference_mode::InferenceMode; @@ -13,17 +12,17 @@ use crate::ephemeral_balancer_runner_params::ephemeral_balancer_runner_params; async fn balancer_runner_serves_the_desired_state_it_starts_with() { let initial_desired_state = BalancerDesiredState { model: AgentDesiredModel::LocalToAgent("initial-embedding-model".to_owned()), - ..BalancerDesiredState::unconfigured(InferenceMode::Embeddings) + inference_mode: InferenceMode::Embeddings, + ..BalancerDesiredState::default() }; let mut params = ephemeral_balancer_runner_params(CancellationToken::new()); - params.runner_config.serving_mode = BalancerServingMode::Embeddings; params.runner_config.state_database_type = - StateDatabaseType::MemoryStartingWith(Box::new(initial_desired_state.clone())); + StateDatabaseType::Memory(Box::new(initial_desired_state.clone())); let runner = BalancerRunner::start(params) .await - .expect("a runner starting with a desired state of its own mode must start"); + .expect("a runner starting with a desired state must start"); assert_eq!( *runner.balancer_desired_state_tx.borrow(), diff --git a/paddler_balancer_runner/tests/balancer_web_admin_panel_omits_the_services_it_does_not_run.rs b/paddler_balancer_runner/tests/balancer_web_admin_panel_omits_the_services_it_does_not_run.rs index 7690c4b7e..c72c1a53d 100644 --- a/paddler_balancer_runner/tests/balancer_web_admin_panel_omits_the_services_it_does_not_run.rs +++ b/paddler_balancer_runner/tests/balancer_web_admin_panel_omits_the_services_it_does_not_run.rs @@ -4,7 +4,6 @@ use std::time::Duration; use tokio_util::sync::CancellationToken; use paddler_balancer::resolved_socket_addr::ResolvedSocketAddr; -use paddler_balancer_runner::balancer_serving_mode::BalancerServingMode; use paddler_test_cluster_harness::ephemeral_loopback_addr::EPHEMERAL_LOOPBACK_ADDR; use crate::ephemeral_balancer_runner_params::ephemeral_balancer_runner_params; @@ -16,7 +15,6 @@ async fn balancer_web_admin_panel_omits_the_services_it_does_not_run() { params.runner_config.buffered_request_timeout = Duration::from_secs(2); params.runner_config.max_buffered_requests = 3; - params.runner_config.serving_mode = BalancerServingMode::Embeddings; params.runner_config.inference_service_configuration.addr = ResolvedSocketAddr { input_addr: "localhost:0".to_owned(), socket_addr: EPHEMERAL_LOOPBACK_ADDR, @@ -32,7 +30,6 @@ async fn balancer_web_admin_panel_omits_the_services_it_does_not_run() { [ ("data-buffered-request-timeout-millis", "2000"), ("data-inference-addr", "localhost:0"), - ("data-inference-mode", "Embeddings"), ("data-management-addr", "localhost:0"), ("data-max-buffered-requests", "3"), ("id", "paddler-dashboard"), diff --git a/paddler_balancer_runner/tests/balancer_web_admin_panel_shows_the_balancer_configuration.rs b/paddler_balancer_runner/tests/balancer_web_admin_panel_shows_the_balancer_configuration.rs index c7d149f71..e52bf70b1 100644 --- a/paddler_balancer_runner/tests/balancer_web_admin_panel_shows_the_balancer_configuration.rs +++ b/paddler_balancer_runner/tests/balancer_web_admin_panel_shows_the_balancer_configuration.rs @@ -6,7 +6,6 @@ use tokio_util::sync::CancellationToken; use paddler_balancer::compatibility::compatibility_service_configuration::CompatibilityServiceConfiguration; use paddler_balancer::resolved_socket_addr::ResolvedSocketAddr; use paddler_balancer::statsd_service::configuration::Configuration as StatsdServiceConfiguration; -use paddler_balancer_runner::balancer_serving_mode::BalancerServingMode; use paddler_test_cluster_harness::ephemeral_loopback_addr::EPHEMERAL_LOOPBACK_ADDR; use crate::ephemeral_balancer_runner_params::ephemeral_balancer_runner_params; @@ -27,16 +26,17 @@ async fn balancer_web_admin_panel_shows_the_balancer_configuration() { params.runner_config.inference_service_configuration.addr = entered_as("localhost:0"); params.runner_config.management_service_configuration.addr = entered_as("[::ffff:127.0.0.1]:0"); params.runner_config.max_buffered_requests = 7; - params.runner_config.serving_mode = BalancerServingMode::TextGeneration { - openai_service_configuration: Some(CompatibilityServiceConfiguration { - addr: entered_as("127.0.0.1:0"), - }), - }; + params.runner_config.openai_service_configuration = Some(CompatibilityServiceConfiguration { + addr: entered_as("127.0.0.1:0"), + }); params.runner_config.statsd_prefix = "dashboard_".to_owned(); params.runner_config.statsd_service_configuration = Some(StatsdServiceConfiguration { statsd_addr: entered_as("localhost:8125"), statsd_reporting_interval: Duration::from_hours(1), }); + params.runner_config.typesafe_service_configuration = Some(CompatibilityServiceConfiguration { + addr: entered_as("[::1]:0"), + }); assert_eq!( rendered_dashboard_attributes(params).await, @@ -44,8 +44,8 @@ async fn balancer_web_admin_panel_shows_the_balancer_configuration() { [ ("data-buffered-request-timeout-millis", "1500"), ("data-compat-openai-addr", "127.0.0.1:0"), + ("data-compat-typesafe-addr", "[::1]:0"), ("data-inference-addr", "localhost:0"), - ("data-inference-mode", "TextGeneration"), ("data-management-addr", "[::ffff:127.0.0.1]:0"), ("data-max-buffered-requests", "7"), ("data-statsd-addr", "localhost:8125"), diff --git a/paddler_balancer_runner/tests/balancer_web_admin_panel_shows_the_typesafe_compatibility_address.rs b/paddler_balancer_runner/tests/balancer_web_admin_panel_shows_the_typesafe_compatibility_address.rs deleted file mode 100644 index 31a97d48c..000000000 --- a/paddler_balancer_runner/tests/balancer_web_admin_panel_shows_the_typesafe_compatibility_address.rs +++ /dev/null @@ -1,36 +0,0 @@ -use tokio_util::sync::CancellationToken; - -use paddler_balancer::compatibility::compatibility_service_configuration::CompatibilityServiceConfiguration; -use paddler_balancer::resolved_socket_addr::ResolvedSocketAddr; -use paddler_balancer_runner::balancer_serving_mode::BalancerServingMode; -use paddler_test_cluster_harness::ephemeral_loopback_addr::EPHEMERAL_LOOPBACK_ADDR; - -use crate::ephemeral_balancer_runner_params::ephemeral_balancer_runner_params; -use crate::rendered_dashboard_attributes::rendered_dashboard_attributes; - -#[tokio::test] -async fn balancer_web_admin_panel_shows_the_typesafe_compatibility_address() { - let mut params = ephemeral_balancer_runner_params(CancellationToken::new()); - - params.runner_config.serving_mode = BalancerServingMode::Decision { - typesafe_service_configuration: Some(CompatibilityServiceConfiguration { - addr: ResolvedSocketAddr { - input_addr: "localhost:0".to_owned(), - socket_addr: EPHEMERAL_LOOPBACK_ADDR, - }, - }), - }; - - let dashboard_attributes = rendered_dashboard_attributes(params).await; - - assert_eq!( - [ - dashboard_attributes.get("data-compat-typesafe-addr"), - dashboard_attributes.get("data-inference-mode"), - ], - [ - Some(&"localhost:0".to_owned()), - Some(&"Decision".to_owned()) - ] - ); -} diff --git a/paddler_balancer_runner/tests/ephemeral_balancer_runner_params.rs b/paddler_balancer_runner/tests/ephemeral_balancer_runner_params.rs index 0210d2c80..f1ca8bb5c 100644 --- a/paddler_balancer_runner/tests/ephemeral_balancer_runner_params.rs +++ b/paddler_balancer_runner/tests/ephemeral_balancer_runner_params.rs @@ -5,7 +5,6 @@ use paddler_balancer::management_service::configuration::Configuration as Manage use paddler_balancer::resolved_socket_addr::ResolvedSocketAddr; use paddler_balancer_runner::balancer_runner_config::BalancerRunnerConfig; use paddler_balancer_runner::balancer_runner_params::BalancerRunnerParams; -use paddler_balancer_runner::balancer_serving_mode::BalancerServingMode; use paddler_state_database::state_database_type::StateDatabaseType; use paddler_test_cluster_harness::ephemeral_loopback_addr::EPHEMERAL_LOOPBACK_ADDR; use paddler_test_cluster_harness::longer_than_any_test_run::LONGER_THAN_ANY_TEST_RUN; @@ -27,12 +26,11 @@ pub fn ephemeral_balancer_runner_params( cors_allowed_hosts: vec![], }, max_buffered_requests: 30, - serving_mode: BalancerServingMode::TextGeneration { - openai_service_configuration: None, - }, - state_database_type: StateDatabaseType::Memory, + openai_service_configuration: None, + state_database_type: StateDatabaseType::Memory(Box::default()), statsd_prefix: "paddler_balancer_runner_test_".to_owned(), statsd_service_configuration: None, + typesafe_service_configuration: None, #[cfg(feature = "web_admin_panel")] web_admin_panel_service_configuration: None, }, diff --git a/paddler_balancer_runner/tests/main.rs b/paddler_balancer_runner/tests/main.rs index 59ab9fda7..0d0d52080 100644 --- a/paddler_balancer_runner/tests/main.rs +++ b/paddler_balancer_runner/tests/main.rs @@ -6,7 +6,6 @@ mod balancer_runner_fails_to_start_when_an_address_is_taken; mod balancer_runner_fails_to_start_when_state_database_file_is_corrupt; mod balancer_runner_fails_to_start_with_a_zero_statsd_reporting_interval; mod balancer_runner_preserves_the_persisted_desired_state; -mod balancer_runner_refuses_a_stored_state_of_another_mode; mod balancer_runner_releases_its_ports_when_dropped; mod balancer_runner_serves_on_the_addresses_it_reports; mod balancer_runner_serves_the_desired_state_it_starts_with; @@ -15,8 +14,6 @@ mod balancer_shutdown_releases_buffered_request_with_shutdown_error; mod balancer_web_admin_panel_omits_the_services_it_does_not_run; #[cfg(feature = "web_admin_panel")] mod balancer_web_admin_panel_shows_the_balancer_configuration; -#[cfg(feature = "web_admin_panel")] -mod balancer_web_admin_panel_shows_the_typesafe_compatibility_address; mod ephemeral_balancer_runner_params; #[cfg(feature = "web_admin_panel")] #[cfg(test)] diff --git a/paddler_cli/src/cmd/balancer.rs b/paddler_cli/src/cmd/balancer.rs index 52eaa6bf6..2ad8b0b18 100644 --- a/paddler_cli/src/cmd/balancer.rs +++ b/paddler_cli/src/cmd/balancer.rs @@ -1,32 +1,175 @@ +use std::io::stdout; +use std::net::Ipv4Addr; +use std::net::SocketAddr; +use std::time::Duration; + use anyhow::Result; use async_trait::async_trait; -use clap::Subcommand; +use clap::Parser; use command_handler::handler::Handler; use tokio_util::sync::CancellationToken; +use trzcina::ServiceShutdownOptions; + +use paddler_balancer::compatibility::compatibility_service_configuration::CompatibilityServiceConfiguration; +use paddler_balancer::inference_service::configuration::Configuration as InferenceServiceConfiguration; +use paddler_balancer::management_service::configuration::Configuration as ManagementServiceConfiguration; +use paddler_balancer::resolved_socket_addr::ResolvedSocketAddr; +use paddler_balancer::statsd_service::configuration::Configuration as StatsdServiceConfiguration; +#[cfg(feature = "web_admin_panel")] +use paddler_balancer::web_admin_panel_service::configuration::Configuration as WebAdminPanelServiceConfiguration; +use paddler_balancer_runner::balancer_defaults::BalancerDefaults; +use paddler_balancer_runner::balancer_runner_config::BalancerRunnerConfig; +use paddler_balancer_runner::balancer_service_bundle::BalancerServiceBundle; +use paddler_service_thread::run_service_manager::run_service_manager; +use paddler_state_database::state_database_type::StateDatabaseType; + +use super::value_parser::parse_duration::parse_duration; +use super::value_parser::parse_socket_addr::parse_socket_addr; + +#[derive(Parser)] +pub struct Balancer { + #[arg( + long, + default_value = BalancerDefaults::BUFFERED_REQUEST_TIMEOUT.as_millis().to_string(), + value_parser = parse_duration + )] + /// Specifies how long a request can stay in the buffer before it is processed. + /// If the request stays in the buffer longer than this time, it is rejected with the 504 error + buffered_request_timeout: Duration, + + #[arg(long, value_parser = parse_socket_addr)] + /// Address of the OpenAI-compatible API server (enabled only if this address is specified) + compat_openai_addr: Option, + + #[arg(long, value_parser = parse_socket_addr)] + /// Address of the TypeSafe-compatible API server (enabled only if this address is specified) + compat_typesafe_addr: Option, + + #[arg( + long, + default_value = SocketAddr::from((Ipv4Addr::LOCALHOST, BalancerDefaults::INFERENCE_PORT)).to_string(), + value_parser = parse_socket_addr + )] + /// Address of the inference server + inference_addr: ResolvedSocketAddr, + + #[arg( + long, + default_value = BalancerDefaults::INFERENCE_ITEM_TIMEOUT.as_millis().to_string(), + value_parser = parse_duration + )] + /// The timeout (in milliseconds) for generating a single token or a single embedding + inference_item_timeout: Duration, + + #[arg( + long = "inference-cors-allowed-host", + action = clap::ArgAction::Append + )] + /// Allowed CORS host for the inference service (can be specified multiple times) + inference_cors_allowed_hosts: Vec, + + #[arg( + long, + default_value = SocketAddr::from((Ipv4Addr::LOCALHOST, BalancerDefaults::MANAGEMENT_PORT)).to_string(), + value_parser = parse_socket_addr + )] + /// This is where you can manage your Paddler setup and the agents connect to + management_addr: ResolvedSocketAddr, -use super::balancer_decision::BalancerDecision; -use super::balancer_embeddings::BalancerEmbeddings; -use super::balancer_text_generation::BalancerTextGeneration; - -#[derive(Subcommand)] -pub enum Balancer { - /// Answers questions about a document with a kev decision model - Decision(Box), - /// Serves embeddings for documents - Embeddings(Box), - /// Generates tokens from conversations and raw prompts - TextGeneration(Box), + #[arg( + long = "management-cors-allowed-host", + action = clap::ArgAction::Append + )] + /// Allowed CORS host for the management service (can be specified multiple times) + management_cors_allowed_hosts: Vec, + + #[arg(long, default_value_t = BalancerDefaults::MAX_BUFFERED_REQUESTS)] + /// The maximum number of buffered requests. + /// If the buffer is full then new requests are rejected with the 503 error + max_buffered_requests: u64, + + #[arg(long, default_value = "memory://")] + /// Balancer state database URL. Supported: memory, memory://, or (optional) + state_database: StateDatabaseType, + + #[arg(long, value_parser = parse_socket_addr)] + /// Address for the statsd server to report metrics to (enabled only if this address is specified) + statsd_addr: Option, + + #[arg(long, default_value = BalancerDefaults::STATSD_PREFIX)] + /// Prefix for statsd metrics + statsd_prefix: String, + + #[arg( + long, + default_value = BalancerDefaults::STATSD_REPORTING_INTERVAL.as_millis().to_string(), + value_parser = parse_duration + )] + /// Interval (in milliseconds) at which the balancer will report metrics to statsd + statsd_reporting_interval: Duration, + + #[cfg(feature = "web_admin_panel")] + #[arg(long, default_value = None, value_parser = parse_socket_addr)] + /// Address of the web admin panel (enabled only if this address is specified) + web_admin_panel_addr: Option, } #[async_trait(?Send)] impl Handler for Balancer { async fn handle(self, shutdown: CancellationToken) -> Result<()> { - match self { - Self::Decision(balancer_decision) => (*balancer_decision).handle(shutdown).await, - Self::Embeddings(balancer_embeddings) => (*balancer_embeddings).handle(shutdown).await, - Self::TextGeneration(balancer_text_generation) => { - (*balancer_text_generation).handle(shutdown).await - } - } + let Self { + buffered_request_timeout, + compat_openai_addr, + compat_typesafe_addr, + inference_addr, + inference_item_timeout, + inference_cors_allowed_hosts, + management_addr, + management_cors_allowed_hosts, + max_buffered_requests, + state_database, + statsd_addr, + statsd_prefix, + statsd_reporting_interval, + #[cfg(feature = "web_admin_panel")] + web_admin_panel_addr, + } = self; + + let bundle = BalancerServiceBundle::new(BalancerRunnerConfig { + buffered_request_timeout, + inference_service_configuration: InferenceServiceConfiguration { + addr: inference_addr, + cors_allowed_hosts: inference_cors_allowed_hosts, + inference_item_timeout, + }, + management_service_configuration: ManagementServiceConfiguration { + addr: management_addr, + cors_allowed_hosts: management_cors_allowed_hosts, + }, + max_buffered_requests, + openai_service_configuration: compat_openai_addr + .map(|addr| CompatibilityServiceConfiguration { addr }), + state_database_type: state_database, + statsd_prefix, + statsd_service_configuration: statsd_addr.map(|statsd_addr| { + StatsdServiceConfiguration { + statsd_addr, + statsd_reporting_interval, + } + }), + typesafe_service_configuration: compat_typesafe_addr + .map(|addr| CompatibilityServiceConfiguration { addr }), + #[cfg(feature = "web_admin_panel")] + web_admin_panel_service_configuration: web_admin_panel_addr.map( + |web_admin_panel_addr| WebAdminPanelServiceConfiguration { + addr: web_admin_panel_addr.socket_addr, + }, + ), + }) + .await?; + + bundle.addresses.write_json_line(stdout().lock())?; + + run_service_manager(bundle, shutdown, ServiceShutdownOptions::default()).await } } diff --git a/paddler_cli/src/cmd/balancer_decision.rs b/paddler_cli/src/cmd/balancer_decision.rs deleted file mode 100644 index 74175ddca..000000000 --- a/paddler_cli/src/cmd/balancer_decision.rs +++ /dev/null @@ -1,38 +0,0 @@ -use anyhow::Result; -use async_trait::async_trait; -use clap::Args; -use command_handler::handler::Handler; -use tokio_util::sync::CancellationToken; - -use paddler_balancer::compatibility::compatibility_service_configuration::CompatibilityServiceConfiguration; -use paddler_balancer::resolved_socket_addr::ResolvedSocketAddr; -use paddler_balancer_runner::balancer_serving_mode::BalancerServingMode; - -use super::balancer_shared_arguments::BalancerSharedArguments; -use super::value_parser::parse_socket_addr::parse_socket_addr; - -#[derive(Args)] -pub struct BalancerDecision { - #[arg(long, value_parser = parse_socket_addr)] - /// Address of the TypeSafe-compatible System One API server (enabled only if this address is specified) - compat_typesafe_addr: Option, - - #[command(flatten)] - shared_arguments: BalancerSharedArguments, -} - -#[async_trait(?Send)] -impl Handler for BalancerDecision { - async fn handle(self, shutdown: CancellationToken) -> Result<()> { - self.shared_arguments - .run( - BalancerServingMode::Decision { - typesafe_service_configuration: self - .compat_typesafe_addr - .map(|addr| CompatibilityServiceConfiguration { addr }), - }, - shutdown, - ) - .await - } -} diff --git a/paddler_cli/src/cmd/balancer_embeddings.rs b/paddler_cli/src/cmd/balancer_embeddings.rs deleted file mode 100644 index cb367be16..000000000 --- a/paddler_cli/src/cmd/balancer_embeddings.rs +++ /dev/null @@ -1,24 +0,0 @@ -use anyhow::Result; -use async_trait::async_trait; -use clap::Args; -use command_handler::handler::Handler; -use tokio_util::sync::CancellationToken; - -use paddler_balancer_runner::balancer_serving_mode::BalancerServingMode; - -use super::balancer_shared_arguments::BalancerSharedArguments; - -#[derive(Args)] -pub struct BalancerEmbeddings { - #[command(flatten)] - shared_arguments: BalancerSharedArguments, -} - -#[async_trait(?Send)] -impl Handler for BalancerEmbeddings { - async fn handle(self, shutdown: CancellationToken) -> Result<()> { - self.shared_arguments - .run(BalancerServingMode::Embeddings, shutdown) - .await - } -} diff --git a/paddler_cli/src/cmd/balancer_shared_arguments.rs b/paddler_cli/src/cmd/balancer_shared_arguments.rs deleted file mode 100644 index cd57d50a0..000000000 --- a/paddler_cli/src/cmd/balancer_shared_arguments.rs +++ /dev/null @@ -1,163 +0,0 @@ -use std::io::stdout; -use std::net::Ipv4Addr; -use std::net::SocketAddr; -use std::time::Duration; - -use anyhow::Result; -use clap::Args; -use tokio_util::sync::CancellationToken; -use trzcina::ServiceShutdownOptions; - -use paddler_balancer::inference_service::configuration::Configuration as InferenceServiceConfiguration; -use paddler_balancer::management_service::configuration::Configuration as ManagementServiceConfiguration; -use paddler_balancer::resolved_socket_addr::ResolvedSocketAddr; -use paddler_balancer::statsd_service::configuration::Configuration as StatsdServiceConfiguration; -#[cfg(feature = "web_admin_panel")] -use paddler_balancer::web_admin_panel_service::configuration::Configuration as WebAdminPanelServiceConfiguration; -use paddler_balancer_runner::balancer_defaults::BalancerDefaults; -use paddler_balancer_runner::balancer_runner_config::BalancerRunnerConfig; -use paddler_balancer_runner::balancer_service_bundle::BalancerServiceBundle; -use paddler_balancer_runner::balancer_serving_mode::BalancerServingMode; -use paddler_service_thread::run_service_manager::run_service_manager; -use paddler_state_database::state_database_type::StateDatabaseType; - -use super::value_parser::parse_duration::parse_duration; -use super::value_parser::parse_socket_addr::parse_socket_addr; - -#[derive(Args)] -pub struct BalancerSharedArguments { - #[arg( - long, - default_value = BalancerDefaults::BUFFERED_REQUEST_TIMEOUT.as_millis().to_string(), - value_parser = parse_duration - )] - /// Specifies how long a request can stay in the buffer before it is processed. - /// If the request stays in the buffer longer than this time, it is rejected with the 504 error - pub buffered_request_timeout: Duration, - - #[arg( - long, - default_value = SocketAddr::from((Ipv4Addr::LOCALHOST, BalancerDefaults::INFERENCE_PORT)).to_string(), - value_parser = parse_socket_addr - )] - /// Address of the inference server - pub inference_addr: ResolvedSocketAddr, - - #[arg( - long, - default_value = BalancerDefaults::INFERENCE_ITEM_TIMEOUT.as_millis().to_string(), - value_parser = parse_duration - )] - /// The timeout (in milliseconds) for generating a single token or a single embedding - pub inference_item_timeout: Duration, - - #[arg( - long = "inference-cors-allowed-host", - action = clap::ArgAction::Append - )] - /// Allowed CORS host for the inference service (can be specified multiple times) - pub inference_cors_allowed_hosts: Vec, - - #[arg( - long, - default_value = SocketAddr::from((Ipv4Addr::LOCALHOST, BalancerDefaults::MANAGEMENT_PORT)).to_string(), - value_parser = parse_socket_addr - )] - /// This is where you can manage your Paddler setup and the agents connect to - pub management_addr: ResolvedSocketAddr, - - #[arg( - long = "management-cors-allowed-host", - action = clap::ArgAction::Append - )] - /// Allowed CORS host for the management service (can be specified multiple times) - pub management_cors_allowed_hosts: Vec, - - #[arg(long, default_value_t = BalancerDefaults::MAX_BUFFERED_REQUESTS)] - /// The maximum number of buffered requests. - /// If the buffer is full then new requests are rejected with the 503 error - pub max_buffered_requests: u64, - - #[arg(long, default_value = "memory://")] - /// Balancer state database URL. Supported: memory, memory://, or (optional) - pub state_database: StateDatabaseType, - - #[arg(long, value_parser = parse_socket_addr)] - /// Address for the statsd server to report metrics to (enabled only if this address is specified) - pub statsd_addr: Option, - - #[arg(long, default_value = BalancerDefaults::STATSD_PREFIX)] - /// Prefix for statsd metrics - pub statsd_prefix: String, - - #[arg( - long, - default_value = BalancerDefaults::STATSD_REPORTING_INTERVAL.as_millis().to_string(), - value_parser = parse_duration - )] - /// Interval (in milliseconds) at which the balancer will report metrics to statsd - pub statsd_reporting_interval: Duration, - - #[cfg(feature = "web_admin_panel")] - #[arg(long, default_value = None, value_parser = parse_socket_addr)] - /// Address of the web admin panel (enabled only if this address is specified) - pub web_admin_panel_addr: Option, -} - -impl BalancerSharedArguments { - pub async fn run( - self, - serving_mode: BalancerServingMode, - shutdown: CancellationToken, - ) -> Result<()> { - let Self { - buffered_request_timeout, - inference_addr, - inference_item_timeout, - inference_cors_allowed_hosts, - management_addr, - management_cors_allowed_hosts, - max_buffered_requests, - state_database, - statsd_addr, - statsd_prefix, - statsd_reporting_interval, - #[cfg(feature = "web_admin_panel")] - web_admin_panel_addr, - } = self; - - let bundle = BalancerServiceBundle::new(BalancerRunnerConfig { - buffered_request_timeout, - inference_service_configuration: InferenceServiceConfiguration { - addr: inference_addr, - cors_allowed_hosts: inference_cors_allowed_hosts, - inference_item_timeout, - }, - management_service_configuration: ManagementServiceConfiguration { - addr: management_addr, - cors_allowed_hosts: management_cors_allowed_hosts, - }, - max_buffered_requests, - serving_mode, - state_database_type: state_database, - statsd_prefix, - statsd_service_configuration: statsd_addr.map(|statsd_addr| { - StatsdServiceConfiguration { - statsd_addr, - statsd_reporting_interval, - } - }), - #[cfg(feature = "web_admin_panel")] - web_admin_panel_service_configuration: web_admin_panel_addr.map( - |web_admin_panel_addr| WebAdminPanelServiceConfiguration { - addr: web_admin_panel_addr.socket_addr, - }, - ), - }) - .await?; - - bundle.addresses.write_json_line(stdout().lock())?; - - run_service_manager(bundle, shutdown, ServiceShutdownOptions::default()).await - } -} diff --git a/paddler_cli/src/cmd/balancer_text_generation.rs b/paddler_cli/src/cmd/balancer_text_generation.rs deleted file mode 100644 index 7003021d5..000000000 --- a/paddler_cli/src/cmd/balancer_text_generation.rs +++ /dev/null @@ -1,38 +0,0 @@ -use anyhow::Result; -use async_trait::async_trait; -use clap::Args; -use command_handler::handler::Handler; -use tokio_util::sync::CancellationToken; - -use paddler_balancer::compatibility::compatibility_service_configuration::CompatibilityServiceConfiguration; -use paddler_balancer::resolved_socket_addr::ResolvedSocketAddr; -use paddler_balancer_runner::balancer_serving_mode::BalancerServingMode; - -use super::balancer_shared_arguments::BalancerSharedArguments; -use super::value_parser::parse_socket_addr::parse_socket_addr; - -#[derive(Args)] -pub struct BalancerTextGeneration { - #[arg(long, value_parser = parse_socket_addr)] - /// Address of the OpenAI-compatible API server (enabled only if this address is specified) - compat_openai_addr: Option, - - #[command(flatten)] - shared_arguments: BalancerSharedArguments, -} - -#[async_trait(?Send)] -impl Handler for BalancerTextGeneration { - async fn handle(self, shutdown: CancellationToken) -> Result<()> { - self.shared_arguments - .run( - BalancerServingMode::TextGeneration { - openai_service_configuration: self - .compat_openai_addr - .map(|addr| CompatibilityServiceConfiguration { addr }), - }, - shutdown, - ) - .await - } -} diff --git a/paddler_cli/src/cmd/mod.rs b/paddler_cli/src/cmd/mod.rs index 823872b8b..1ca021419 100644 --- a/paddler_cli/src/cmd/mod.rs +++ b/paddler_cli/src/cmd/mod.rs @@ -1,7 +1,3 @@ pub mod agent; pub mod balancer; -pub mod balancer_decision; -pub mod balancer_embeddings; -pub mod balancer_shared_arguments; -pub mod balancer_text_generation; pub mod value_parser; diff --git a/paddler_cli/src/lib.rs b/paddler_cli/src/lib.rs index 5781cafca..438cec752 100644 --- a/paddler_cli/src/lib.rs +++ b/paddler_cli/src/lib.rs @@ -30,9 +30,8 @@ struct Cli { enum Commands { /// Runs inference for the balancer it connects to Agent(Agent), - /// Distributes incoming requests among agents, serving one inference mode - #[command(subcommand)] - Balancer(Balancer), + /// Distributes incoming requests among agents + Balancer(Box), } pub fn run() -> Result<()> { @@ -43,7 +42,7 @@ pub fn run() -> Result<()> { match Cli::parse().command { Commands::Agent(handler) => handler.handle(shutdown).await, - Commands::Balancer(handler) => handler.handle(shutdown).await, + Commands::Balancer(handler) => (*handler).handle(shutdown).await, } }) } diff --git a/paddler_cli/tests/main.rs b/paddler_cli/tests/main.rs index 099f17fa6..60918d803 100644 --- a/paddler_cli/tests/main.rs +++ b/paddler_cli/tests/main.rs @@ -1,6 +1,4 @@ mod clap_usage_error_exit_code; mod paddler_agent_rejects_more_slots_than_llama_cpp_can_serve; -mod paddler_balancer_decision_exits_with_a_failure_when_its_management_address_is_taken; -mod paddler_balancer_refuses_compatibility_flags_of_other_modes; -mod paddler_balancer_text_generation_exits_with_a_failure_when_its_management_address_is_taken; +mod paddler_balancer_exits_with_a_failure_when_its_management_address_is_taken; mod paddler_without_a_subcommand_prints_help_and_exits_with_a_usage_error; diff --git a/paddler_cli/tests/paddler_agent_rejects_more_slots_than_llama_cpp_can_serve.rs b/paddler_cli/tests/paddler_agent_rejects_more_slots_than_llama_cpp_can_serve.rs index 0a8261d1d..59fb19b1e 100644 --- a/paddler_cli/tests/paddler_agent_rejects_more_slots_than_llama_cpp_can_serve.rs +++ b/paddler_cli/tests/paddler_agent_rejects_more_slots_than_llama_cpp_can_serve.rs @@ -1,6 +1,6 @@ use std::process::Command; -const CLAP_USAGE_ERROR_EXIT_CODE: i32 = 2; +use crate::clap_usage_error_exit_code::CLAP_USAGE_ERROR_EXIT_CODE; #[test] fn paddler_agent_rejects_more_slots_than_llama_cpp_can_serve() { diff --git a/paddler_cli/tests/paddler_balancer_decision_exits_with_a_failure_when_its_management_address_is_taken.rs b/paddler_cli/tests/paddler_balancer_exits_with_a_failure_when_its_management_address_is_taken.rs similarity index 87% rename from paddler_cli/tests/paddler_balancer_decision_exits_with_a_failure_when_its_management_address_is_taken.rs rename to paddler_cli/tests/paddler_balancer_exits_with_a_failure_when_its_management_address_is_taken.rs index 3d1117bad..e5110a7f3 100644 --- a/paddler_cli/tests/paddler_balancer_decision_exits_with_a_failure_when_its_management_address_is_taken.rs +++ b/paddler_cli/tests/paddler_balancer_exits_with_a_failure_when_its_management_address_is_taken.rs @@ -4,7 +4,7 @@ use std::process::Command; const FAILED_MAIN_EXIT_CODE: i32 = 1; #[test] -fn paddler_balancer_decision_exits_with_a_failure_when_its_management_address_is_taken() { +fn paddler_balancer_exits_with_a_failure_when_its_management_address_is_taken() { let taken_listener = TcpListener::bind("127.0.0.1:0") .expect("a loopback listener must bind to an ephemeral port"); let taken_address = taken_listener @@ -13,7 +13,6 @@ fn paddler_balancer_decision_exits_with_a_failure_when_its_management_address_is let balancer_output = Command::new(env!("CARGO_BIN_EXE_paddler")) .arg("balancer") - .arg("decision") .arg("--inference-addr") .arg("127.0.0.1:0") .arg("--management-addr") diff --git a/paddler_cli/tests/paddler_balancer_refuses_compatibility_flags_of_other_modes.rs b/paddler_cli/tests/paddler_balancer_refuses_compatibility_flags_of_other_modes.rs deleted file mode 100644 index 6b31d14ee..000000000 --- a/paddler_cli/tests/paddler_balancer_refuses_compatibility_flags_of_other_modes.rs +++ /dev/null @@ -1,28 +0,0 @@ -use std::process::Command; - -use crate::clap_usage_error_exit_code::CLAP_USAGE_ERROR_EXIT_CODE; - -#[test] -fn paddler_balancer_refuses_compatibility_flags_of_other_modes() { - for (inference_mode, compatibility_flag) in [ - ("decision", "--compat-openai-addr"), - ("embeddings", "--compat-openai-addr"), - ("embeddings", "--compat-typesafe-addr"), - ("text-generation", "--compat-typesafe-addr"), - ] { - let balancer_output = Command::new(env!("CARGO_BIN_EXE_paddler")) - .arg("balancer") - .arg(inference_mode) - .arg(compatibility_flag) - .arg("127.0.0.1:0") - .output() - .expect("paddler balancer must run"); - - assert_eq!( - balancer_output.status.code(), - Some(CLAP_USAGE_ERROR_EXIT_CODE), - "{inference_mode} must refuse {compatibility_flag}" - ); - assert!(balancer_output.stdout.is_empty()); - } -} diff --git a/paddler_cli/tests/paddler_balancer_text_generation_exits_with_a_failure_when_its_management_address_is_taken.rs b/paddler_cli/tests/paddler_balancer_text_generation_exits_with_a_failure_when_its_management_address_is_taken.rs deleted file mode 100644 index e4dc04071..000000000 --- a/paddler_cli/tests/paddler_balancer_text_generation_exits_with_a_failure_when_its_management_address_is_taken.rs +++ /dev/null @@ -1,26 +0,0 @@ -use std::net::TcpListener; -use std::process::Command; - -const FAILED_MAIN_EXIT_CODE: i32 = 1; - -#[test] -fn paddler_balancer_text_generation_exits_with_a_failure_when_its_management_address_is_taken() { - let taken_listener = TcpListener::bind("127.0.0.1:0") - .expect("a loopback listener must bind to an ephemeral port"); - let taken_address = taken_listener - .local_addr() - .expect("the bound listener must report its address"); - - let balancer_output = Command::new(env!("CARGO_BIN_EXE_paddler")) - .arg("balancer") - .arg("text-generation") - .arg("--inference-addr") - .arg("127.0.0.1:0") - .arg("--management-addr") - .arg(taken_address.to_string()) - .output() - .expect("paddler balancer must run"); - - assert_eq!(balancer_output.status.code(), Some(FAILED_MAIN_EXIT_CODE)); - assert!(balancer_output.stdout.is_empty()); -} diff --git a/paddler_cli_tests/src/start_subprocess_cluster.rs b/paddler_cli_tests/src/start_subprocess_cluster.rs index fea74a170..b79d1849f 100644 --- a/paddler_cli_tests/src/start_subprocess_cluster.rs +++ b/paddler_cli_tests/src/start_subprocess_cluster.rs @@ -1,7 +1,6 @@ use anyhow::Result; use tokio_util::sync::CancellationToken; -use paddler_messaging::inference_mode::InferenceMode; use paddler_test_cluster_harness::cluster::Cluster; use paddler_test_cluster_harness::cluster_params::ClusterParams; use paddler_test_cluster_harness::ephemeral_loopback_addr::EPHEMERAL_LOOPBACK_ADDR; @@ -24,21 +23,11 @@ pub async fn start_subprocess_cluster( }: ClusterParams, ) -> Result { let ephemeral_loopback_addr = EPHEMERAL_LOOPBACK_ADDR.to_string(); - let mut balancer_arguments = match desired_state.inference_mode() { - InferenceMode::Decision => vec![ - "decision".to_owned(), - "--compat-typesafe-addr".to_owned(), - ephemeral_loopback_addr.clone(), - ], - InferenceMode::Embeddings => vec!["embeddings".to_owned()], - InferenceMode::TextGeneration => vec![ - "text-generation".to_owned(), - "--compat-openai-addr".to_owned(), - ephemeral_loopback_addr.clone(), - ], - }; - - balancer_arguments.extend([ + let mut balancer_arguments = vec![ + "--compat-openai-addr".to_owned(), + ephemeral_loopback_addr.clone(), + "--compat-typesafe-addr".to_owned(), + ephemeral_loopback_addr.clone(), "--inference-addr".to_owned(), ephemeral_loopback_addr.clone(), "--management-addr".to_owned(), @@ -51,7 +40,7 @@ pub async fn start_subprocess_cluster( buffered_request_timeout.as_millis().to_string(), "--inference-item-timeout".to_owned(), inference_item_timeout.as_millis().to_string(), - ]); + ]; for allowed_host in inference_cors_allowed_hosts { balancer_arguments.extend(["--inference-cors-allowed-host".to_owned(), allowed_host]); diff --git a/paddler_cli_tests/tests/balancer_distributes_buffered_requests_across_two_agents.rs b/paddler_cli_tests/tests/balancer_distributes_buffered_requests_across_two_agents.rs index b5092f13c..8f5e1b776 100644 --- a/paddler_cli_tests/tests/balancer_distributes_buffered_requests_across_two_agents.rs +++ b/paddler_cli_tests/tests/balancer_distributes_buffered_requests_across_two_agents.rs @@ -2,15 +2,14 @@ use std::num::NonZeroU32; -use futures_util::StreamExt as _; use tokio_util::sync::CancellationToken; use paddler_cli_tests::start_subprocess_cluster::start_subprocess_cluster; -use paddler_messaging::inference_client::message::Message; use paddler_messaging::request_params::continue_from_raw_prompt_params::ContinueFromRawPromptParams; use paddler_test_cluster_harness::agent_config::AgentConfig; use paddler_test_cluster_harness::cluster_desired_state::ClusterDesiredState; use paddler_test_cluster_harness::cluster_params::ClusterParams; +use paddler_test_cluster_harness::collect_generated_tokens::collect_generated_tokens; use paddler_test_cluster_harness::model_card::qwen3_0_6b::qwen3_0_6b; #[tokio::test(flavor = "multi_thread")] @@ -55,24 +54,14 @@ async fn balancer_distributes_buffered_requests_across_two_agents() { streams.push(stream); } - let mut successful_responses = 0; - - for mut stream in streams { - if let Some(item) = stream.next().await { - match item.expect("the message must be readable") { - Message::Response(_) => successful_responses += 1, - Message::Error(envelope) => { - panic!( - "expected success, got error {}: {}", - envelope.error.code, envelope.error.description - ); - } - } - } + for stream in streams { + collect_generated_tokens(stream) + .await + .expect("every buffered request must be served") + .summary() + .expect("every served request must finish with a summary"); } - assert_eq!(successful_responses, 5); - cluster .shutdown() .await diff --git a/paddler_cli_tests/tests/balancer_serves_every_service_within_the_default_macos_open_file_limit.rs b/paddler_cli_tests/tests/balancer_serves_every_service_within_the_default_macos_open_file_limit.rs index 66a64d1ca..cc8d96556 100644 --- a/paddler_cli_tests/tests/balancer_serves_every_service_within_the_default_macos_open_file_limit.rs +++ b/paddler_cli_tests/tests/balancer_serves_every_service_within_the_default_macos_open_file_limit.rs @@ -29,9 +29,10 @@ async fn balancer_serves_every_service_within_the_default_macos_open_file_limit( let running_balancer = spawn_balancer_subprocess( env!("CARGO_BIN_EXE_paddler_cluster_node"), [ - "text-generation", "--compat-openai-addr", &ephemeral_loopback_addr, + "--compat-typesafe-addr", + &ephemeral_loopback_addr, "--inference-addr", &ephemeral_loopback_addr, "--management-addr", @@ -57,6 +58,9 @@ async fn balancer_serves_every_service_within_the_default_macos_open_file_limit( running_balancer .compat_openai_base_url() .expect("the OpenAI compatibility address must form a base URL"), + running_balancer + .compat_typesafe_base_url() + .expect("the TypeSafe compatibility address must form a base URL"), ]; let mut health_checks = Vec::new(); @@ -79,6 +83,6 @@ async fn balancer_serves_every_service_within_the_default_macos_open_file_limit( .await .expect("the balancer subprocess must shut down"); - assert_eq!(health_checks, ["OK", "OK", "OK"]); + assert_eq!(health_checks, ["OK", "OK", "OK", "OK"]); assert_eq!(dashboard_status, StatusCode::OK); } diff --git a/paddler_cli_tests/tests/balancer_serves_on_the_addresses_it_announces.rs b/paddler_cli_tests/tests/balancer_serves_on_the_addresses_it_announces.rs index 21b351b70..5561827dc 100644 --- a/paddler_cli_tests/tests/balancer_serves_on_the_addresses_it_announces.rs +++ b/paddler_cli_tests/tests/balancer_serves_on_the_addresses_it_announces.rs @@ -24,13 +24,20 @@ async fn balancer_serves_on_the_addresses_it_announces() { .expect("the announced inference address must serve health checks"); let compat_openai_health = cluster .compat_openai_health_client() - .expect("a text generation cluster must serve OpenAI compatibility") + .expect("the balancer must serve OpenAI compatibility") .get_health(CancellationToken::new()) .await .expect("the announced OpenAI compatibility address must serve health checks"); + let compat_typesafe_health = cluster + .compat_typesafe_health_client() + .expect("the balancer must serve TypeSafe compatibility") + .get_health(CancellationToken::new()) + .await + .expect("the announced TypeSafe compatibility address must serve health checks"); assert_eq!(inference_health, "OK"); assert_eq!(compat_openai_health, "OK"); + assert_eq!(compat_typesafe_health, "OK"); cluster .shutdown() diff --git a/paddler_cli_tests/tests/balancer_serves_typesafe_compatibility_on_the_address_it_announces.rs b/paddler_cli_tests/tests/balancer_serves_typesafe_compatibility_on_the_address_it_announces.rs deleted file mode 100644 index f4d42056b..000000000 --- a/paddler_cli_tests/tests/balancer_serves_typesafe_compatibility_on_the_address_it_announces.rs +++ /dev/null @@ -1,36 +0,0 @@ -use tokio_util::sync::CancellationToken; - -use paddler_cli_tests::start_subprocess_cluster::start_subprocess_cluster; -use paddler_client::reports_health::ReportsHealth as _; -use paddler_messaging::inference_mode::InferenceMode; -use paddler_test_cluster_harness::cluster_desired_state::ClusterDesiredState; -use paddler_test_cluster_harness::cluster_params::ClusterParams; - -#[tokio::test(flavor = "multi_thread")] -async fn balancer_serves_typesafe_compatibility_on_the_address_it_announces() { - let cluster = start_subprocess_cluster( - env!("CARGO_BIN_EXE_paddler_cluster_node"), - ClusterParams { - agents: Vec::new(), - desired_state: ClusterDesiredState::KeepStored(InferenceMode::Decision), - wait_for_slots_ready: false, - ..ClusterParams::default() - }, - ) - .await - .expect("the decision balancer subprocess must start and announce its addresses"); - - let compat_typesafe_health = cluster - .compat_typesafe_health_client() - .expect("a decision cluster must serve TypeSafe compatibility") - .get_health(CancellationToken::new()) - .await - .expect("the announced TypeSafe compatibility address must serve health checks"); - - assert_eq!(compat_typesafe_health, "OK"); - - cluster - .shutdown() - .await - .expect("the subprocess cluster must shut down cleanly"); -} diff --git a/paddler_cli_tests/tests/balancer_web_admin_panel_shows_its_command_line_configuration.rs b/paddler_cli_tests/tests/balancer_web_admin_panel_shows_its_command_line_configuration.rs index fab19751c..5d57f2168 100644 --- a/paddler_cli_tests/tests/balancer_web_admin_panel_shows_its_command_line_configuration.rs +++ b/paddler_cli_tests/tests/balancer_web_admin_panel_shows_its_command_line_configuration.rs @@ -20,11 +20,12 @@ async fn balancer_web_admin_panel_shows_its_command_line_configuration() { let running_balancer = spawn_balancer_subprocess( env!("CARGO_BIN_EXE_paddler_cluster_node"), [ - "text-generation", "--buffered-request-timeout", "1500", "--compat-openai-addr", &ephemeral_loopback_addr, + "--compat-typesafe-addr", + &ephemeral_loopback_addr, "--inference-addr", &ephemeral_loopback_addr, "--management-addr", @@ -67,8 +68,11 @@ async fn balancer_web_admin_panel_shows_its_command_line_configuration() { [ ("data-buffered-request-timeout-millis", "1500"), ("data-compat-openai-addr", ephemeral_loopback_addr.as_str()), + ( + "data-compat-typesafe-addr", + ephemeral_loopback_addr.as_str() + ), ("data-inference-addr", ephemeral_loopback_addr.as_str()), - ("data-inference-mode", "TextGeneration"), ("data-management-addr", ephemeral_loopback_addr.as_str()), ("data-max-buffered-requests", "7"), ("data-statsd-addr", statsd_addr.as_str()), diff --git a/paddler_cli_tests/tests/balancer_without_compatibility_flags_serves_no_compatibility_services.rs b/paddler_cli_tests/tests/balancer_without_compatibility_flags_serves_no_compatibility_services.rs new file mode 100644 index 000000000..d29753210 --- /dev/null +++ b/paddler_cli_tests/tests/balancer_without_compatibility_flags_serves_no_compatibility_services.rs @@ -0,0 +1,36 @@ +use paddler_cli_tests::spawn_balancer_subprocess::spawn_balancer_subprocess; +use paddler_test_cluster_harness::cluster_harness_error::ClusterHarnessError; +use paddler_test_cluster_harness::ephemeral_loopback_addr::EPHEMERAL_LOOPBACK_ADDR; + +#[tokio::test(flavor = "multi_thread")] +async fn balancer_without_compatibility_flags_serves_no_compatibility_services() { + let ephemeral_loopback_addr = EPHEMERAL_LOOPBACK_ADDR.to_string(); + let running_balancer = spawn_balancer_subprocess( + env!("CARGO_BIN_EXE_paddler_cluster_node"), + [ + "--inference-addr", + &ephemeral_loopback_addr, + "--management-addr", + &ephemeral_loopback_addr, + ], + ) + .await + .expect("the balancer must start and announce its addresses"); + + let compat_openai_addr = running_balancer.compat_openai_addr(); + let compat_typesafe_addr = running_balancer.compat_typesafe_addr(); + + running_balancer + .shutdown() + .await + .expect("the balancer subprocess must shut down"); + + assert!(matches!( + compat_openai_addr, + Err(ClusterHarnessError::CompatOpenAIServiceNotServed) + )); + assert!(matches!( + compat_typesafe_addr, + Err(ClusterHarnessError::CompatTypeSafeServiceNotServed) + )); +} diff --git a/paddler_cli_tests/tests/main.rs b/paddler_cli_tests/tests/main.rs index 99522c028..586e11f6f 100644 --- a/paddler_cli_tests/tests/main.rs +++ b/paddler_cli_tests/tests/main.rs @@ -9,7 +9,7 @@ mod balancer_registers_multiple_agents_over_time; #[cfg(feature = "web_admin_panel")] mod balancer_serves_every_service_within_the_default_macos_open_file_limit; mod balancer_serves_on_the_addresses_it_announces; -mod balancer_serves_typesafe_compatibility_on_the_address_it_announces; #[cfg(feature = "web_admin_panel")] mod balancer_web_admin_panel_shows_its_command_line_configuration; +mod balancer_without_compatibility_flags_serves_no_compatibility_services; mod management_metrics_endpoint_sums_the_slots_of_registered_agents; diff --git a/paddler_client/src/client_inference.rs b/paddler_client/src/client_inference.rs index a7a528327..edcaf1b9c 100644 --- a/paddler_client/src/client_inference.rs +++ b/paddler_client/src/client_inference.rs @@ -2,10 +2,12 @@ use std::sync::Arc; use nanoid::nanoid; use serde::Serialize; +use tokio::sync::broadcast; use tokio_util::sync::CancellationToken; use paddler_messaging::api_path::ApiPath; use paddler_messaging::inference_client::message::Message as InferenceMessage; +use paddler_messaging::inference_client::notification::Notification; use paddler_messaging::inference_server::message::Message as InferenceServerMessage; use paddler_messaging::inference_server::request::Request as InferenceServerRequest; use paddler_messaging::jsonrpc::request_envelope::RequestEnvelope; @@ -79,6 +81,11 @@ impl ClientInference { .await } + #[must_use] + pub fn subscribe_to_cluster_inference_mode(&self) -> broadcast::Receiver { + self.inference_socket_pool.subscribe_to_notifications() + } + pub async fn continue_from_conversation_history( &self, cancellation_token: CancellationToken, diff --git a/paddler_client/src/inference_socket/connection.rs b/paddler_client/src/inference_socket/connection.rs index a1a52a38f..c408b9bd4 100644 --- a/paddler_client/src/inference_socket/connection.rs +++ b/paddler_client/src/inference_socket/connection.rs @@ -2,6 +2,7 @@ use std::sync::Arc; use futures_util::StreamExt; use serde_json::to_string; +use tokio::sync::broadcast; use tokio::sync::mpsc; use tokio::sync::mpsc::UnboundedReceiver; use tokio::sync::mpsc::UnboundedSender; @@ -10,6 +11,7 @@ use tokio_tungstenite::connect_async; use url::Url; use paddler_messaging::inference_client::message::Message as InferenceMessage; +use paddler_messaging::inference_client::notification::Notification; use paddler_messaging::inference_server::message::Message as InferenceServerMessage; use paddler_messaging::inference_server::notification::Notification as InferenceServerNotification; use paddler_messaging::request_params::continue_from_conversation_history_params::tool::tool_params::function_call::parameters_schema::validated_parameters_schema::ValidatedParametersSchema; @@ -29,7 +31,10 @@ pub struct Connection { } impl Connection { - pub async fn connect(connection_url: Url) -> Result { + pub async fn connect( + connection_url: Url, + notification_tx: broadcast::Sender, + ) -> Result { let ws_url = url(connection_url)?; let (ws_stream, _) = connect_async(ws_url.as_str()).await?; let (ws_write, ws_read) = ws_stream.split(); @@ -37,7 +42,7 @@ impl Connection { let (write_tx, write_rx) = mpsc::unbounded_channel::(); Ok(Self { - read_task: spawn_read_task(ws_read, pending.clone()), + read_task: spawn_read_task(ws_read, pending.clone(), notification_tx), write_task: spawn_write_task(ws_write, write_rx, pending.clone()), pending, write_tx, @@ -96,6 +101,7 @@ mod tests { use tokio::net::TcpListener; use tokio::net::TcpStream; use tokio::spawn; + use tokio::sync::broadcast; use tokio::task::JoinHandle; use tokio_tungstenite::WebSocketStream; use tokio_tungstenite::accept_async; @@ -153,16 +159,23 @@ mod tests { } async fn connection_to(url: Url) -> Connection { - Connection::connect(url) + let (notification_tx, _notification_rx) = broadcast::channel(1); + + Connection::connect(url, notification_tx) .await .expect("the client must connect to the fixture") } #[tokio::test] async fn connect_fails_for_an_unreachable_server() { + let (notification_tx, _notification_rx) = broadcast::channel(1); + assert!(matches!( - Connection::connect(Url::parse("http://127.0.0.1:1").expect("the test URL must be valid")) - .await, + Connection::connect( + Url::parse("http://127.0.0.1:1").expect("the test URL must be valid"), + notification_tx, + ) + .await, Err(Error::WebSocket(WebSocketError::Io(io_error))) if io_error.kind() == ErrorKind::ConnectionRefused )); diff --git a/paddler_client/src/inference_socket/inbound_message_router.rs b/paddler_client/src/inference_socket/inbound_message_router.rs index faf5d4a6f..3f52e1152 100644 --- a/paddler_client/src/inference_socket/inbound_message_router.rs +++ b/paddler_client/src/inference_socket/inbound_message_router.rs @@ -1,11 +1,14 @@ use std::sync::Arc; +use log::debug; use log::error; use log::warn; use serde_json::from_str; +use tokio::sync::broadcast; use paddler_messaging::inference_client::identified_message::IdentifiedMessage; use paddler_messaging::inference_client::message::Message as InferenceMessage; +use paddler_messaging::inference_client::notification::Notification; use paddler_messaging::inference_client::response::Response; use paddler_messaging::streamable_result::StreamableResult; @@ -26,6 +29,7 @@ struct RequestScopedMessage { } pub struct InboundMessageRouter { + pub notification_tx: broadcast::Sender, pub pending: Arc, } @@ -66,6 +70,13 @@ impl InboundMessageRouter { is_done: true, request_id: envelope.request_id.clone(), }, + InferenceMessage::Notification(notification) => { + if self.notification_tx.send(notification.clone()).is_err() { + debug!("Dropped inference notification: no active subscribers"); + } + + return; + } InferenceMessage::Response(envelope) => RequestScopedMessage { is_done: response_is_terminal(&envelope.response), request_id: envelope.request_id.clone(), @@ -90,6 +101,7 @@ mod tests { use std::sync::Arc; use serde_json::json; + use tokio::sync::broadcast; use tokio::sync::mpsc::error::TryRecvError; use paddler_messaging::embedding_result::EmbeddingResult; @@ -107,7 +119,10 @@ mod tests { let mut response_rx = pending .register("request-1".to_owned()) .expect("an open registry must accept a request"); + let (notification_tx, _notification_rx) = broadcast::channel(1); + InboundMessageRouter { + notification_tx, pending: Arc::clone(&pending), } .route_text( @@ -133,7 +148,10 @@ mod tests { let mut response_rx = pending .register("request-1".to_owned()) .expect("an open registry must accept a request"); + let (notification_tx, _notification_rx) = broadcast::channel(1); + InboundMessageRouter { + notification_tx, pending: Arc::clone(&pending), } .route_text( @@ -154,7 +172,10 @@ mod tests { let mut response_rx = pending .register("request-1".to_owned()) .expect("an open registry must accept a request"); + let (notification_tx, _notification_rx) = broadcast::channel(1); + InboundMessageRouter { + notification_tx, pending: Arc::clone(&pending), } .route_text("not json"); @@ -172,7 +193,10 @@ mod tests { let mut response_rx = pending .register("request-1".to_owned()) .expect("an open registry must accept a request"); + let (notification_tx, _notification_rx) = broadcast::channel(1); + InboundMessageRouter { + notification_tx, pending: Arc::clone(&pending), } .route_text( @@ -199,4 +223,29 @@ mod tests { .is_err_and(|receive_error| receive_error == TryRecvError::Disconnected) ); } + + #[test] + fn a_notification_nobody_listens_to_leaves_requests_pending() { + let pending = Arc::new(PendingRequests::default()); + let mut response_rx = pending + .register("request-1".to_owned()) + .expect("an open registry must accept a request"); + let (notification_tx, notification_rx) = broadcast::channel(1); + + drop(notification_rx); + + InboundMessageRouter { + notification_tx, + pending: Arc::clone(&pending), + } + .route_text( + &json!({ "Notification": { "ClusterInferenceMode": "Embeddings" } }).to_string(), + ); + + assert!( + response_rx + .try_recv() + .is_err_and(|receive_error| receive_error == TryRecvError::Empty) + ); + } } diff --git a/paddler_client/src/inference_socket/pool.rs b/paddler_client/src/inference_socket/pool.rs index f1e4413b0..2138d13df 100644 --- a/paddler_client/src/inference_socket/pool.rs +++ b/paddler_client/src/inference_socket/pool.rs @@ -6,11 +6,13 @@ use std::sync::atomic::Ordering; use serde::Serialize; use serde_json::to_string; use tokio::sync::Mutex; +use tokio::sync::broadcast; use tokio::sync::mpsc::UnboundedReceiver; use tokio_util::sync::CancellationToken; use url::Url; use paddler_messaging::inference_client::message::Message as InferenceMessage; +use paddler_messaging::inference_client::notification::Notification; use crate::error::Error; use crate::error::Result; @@ -27,21 +29,29 @@ struct EstablishedRequest { pub struct Pool { connection_slots: Vec>, next_slot_index: AtomicUsize, + notification_tx: broadcast::Sender, url: Url, } impl Pool { #[must_use] pub fn new(url: Url, capacity: NonZeroUsize) -> Self { + let (notification_tx, _initial_notification_rx) = broadcast::channel(capacity.get()); + Self { connection_slots: (0..capacity.get()) .map(|_slot_index| Mutex::new(ConnectionSlot::Empty)) .collect(), next_slot_index: AtomicUsize::new(0), + notification_tx, url, } } + pub fn subscribe_to_notifications(&self) -> broadcast::Receiver { + self.notification_tx.subscribe() + } + pub async fn send_request( &self, cancellation_token: CancellationToken, @@ -79,7 +89,8 @@ impl Pool { return Ok(connection.clone()); } - let connection = Arc::new(Connection::connect(self.url.clone()).await?); + let connection = + Arc::new(Connection::connect(self.url.clone(), self.notification_tx.clone()).await?); *connection_slot = ConnectionSlot::Connected(connection.clone()); diff --git a/paddler_client/src/inference_socket/spawn_read_task.rs b/paddler_client/src/inference_socket/spawn_read_task.rs index 13e4c2722..87a983ef2 100644 --- a/paddler_client/src/inference_socket/spawn_read_task.rs +++ b/paddler_client/src/inference_socket/spawn_read_task.rs @@ -5,11 +5,14 @@ use futures_util::stream::SplitStream; use log::error; use tokio::net::TcpStream; use tokio::spawn; +use tokio::sync::broadcast; use tokio::task::JoinHandle; use tokio_tungstenite::MaybeTlsStream; use tokio_tungstenite::WebSocketStream; use tokio_tungstenite::tungstenite::Message as WsMessage; +use paddler_messaging::inference_client::notification::Notification; + use crate::inference_socket::inbound_message_router::InboundMessageRouter; use crate::inference_socket::pending_requests::PendingRequests; @@ -19,10 +22,14 @@ type WebSocketReadStream = SplitStream pub fn spawn_read_task( ws_read: WebSocketReadStream, pending: Arc, + notification_tx: broadcast::Sender, ) -> JoinHandle<()> { spawn(async move { let mut ws_read = ws_read; - let router = InboundMessageRouter { pending }; + let router = InboundMessageRouter { + notification_tx, + pending, + }; while let Some(msg_result) = ws_read.next().await { match msg_result { diff --git a/paddler_client_javascript/src/inferenceSocketClient.ts b/paddler_client_javascript/src/inferenceSocketClient.ts index a9a547c8f..42fd770ee 100644 --- a/paddler_client_javascript/src/inferenceSocketClient.ts +++ b/paddler_client_javascript/src/inferenceSocketClient.ts @@ -2,18 +2,29 @@ import { nanoid } from "nanoid"; import { filter, fromEvent, map, takeWhile, type Observable } from "rxjs"; import type { ConversationMessage } from "./schemas/ConversationMessage"; +import type { InferenceMode } from "./schemas/InferenceMode"; +import { InferenceNotificationSchema } from "./schemas/InferenceNotification"; import { InferenceServiceGenerateTokensResponseSchema, type InferenceServiceGenerateTokensResponse, } from "./schemas/InferenceServiceGenerateTokensResponse"; export interface InferenceSocketClient { + clusterInferenceMode$: Observable; continueConversation(params: { enableThinking: boolean; messages: ConversationMessage[]; }): Observable; } +function isNotificationFrame(parsedFrame: unknown): boolean { + return ( + "object" === typeof parsedFrame && + null !== parsedFrame && + "Notification" in parsedFrame + ); +} + export function inferenceSocketClient({ webSocket, }: { @@ -34,6 +45,14 @@ export function inferenceSocketClient({ }), ); + const clusterInferenceMode$: Observable = parsedFrames$.pipe( + filter(isNotificationFrame), + map(function (parsedFrame: unknown): InferenceMode { + return InferenceNotificationSchema.parse(parsedFrame).Notification + .ClusterInferenceMode; + }), + ); + function continueConversation({ enableThinking, messages, @@ -43,6 +62,9 @@ export function inferenceSocketClient({ }): Observable { const requestId = nanoid(); const tokenStream = parsedFrames$.pipe( + filter(function (parsedFrame) { + return !isNotificationFrame(parsedFrame); + }), map(function (parsedFrame: unknown) { return InferenceServiceGenerateTokensResponseSchema.parse(parsedFrame); }), @@ -74,6 +96,7 @@ export function inferenceSocketClient({ } return Object.freeze({ + clusterInferenceMode$, continueConversation, }); } diff --git a/paddler_client_javascript/src/schemas/BalancerDesiredState.ts b/paddler_client_javascript/src/schemas/BalancerDesiredState.ts index 23b49d4f2..d4fe52f61 100644 --- a/paddler_client_javascript/src/schemas/BalancerDesiredState.ts +++ b/paddler_client_javascript/src/schemas/BalancerDesiredState.ts @@ -1,14 +1,20 @@ import { z } from "zod"; import { AgentDesiredModelSchema } from "./AgentDesiredModel"; -import { BalancerInferenceSettingsSchema } from "./BalancerInferenceSettings"; +import { BalancerTextGenerationSettingsSchema } from "./BalancerTextGenerationSettings"; +import { DecisionSettingsSchema } from "./DecisionSettings"; +import { EmbeddingParametersSchema } from "./EmbeddingParameters"; +import { InferenceModeSchema } from "./InferenceMode"; import { ModelRuntimeParametersSchema } from "./ModelRuntimeParameters"; export const BalancerDesiredStateSchema = z .object({ - inference_settings: BalancerInferenceSettingsSchema, + decision: DecisionSettingsSchema, + embeddings: EmbeddingParametersSchema, + inference_mode: InferenceModeSchema, model: AgentDesiredModelSchema, model_runtime_parameters: ModelRuntimeParametersSchema, + text_generation: BalancerTextGenerationSettingsSchema, }) .strict(); diff --git a/paddler_client_javascript/src/schemas/BalancerInferenceSettings.ts b/paddler_client_javascript/src/schemas/BalancerInferenceSettings.ts deleted file mode 100644 index cbf656ffc..000000000 --- a/paddler_client_javascript/src/schemas/BalancerInferenceSettings.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { z } from "zod"; - -import { BalancerTextGenerationSettingsSchema } from "./BalancerTextGenerationSettings"; -import { DecisionSettingsSchema } from "./DecisionSettings"; -import { EmbeddingParametersSchema } from "./EmbeddingParameters"; - -export const BalancerInferenceSettingsSchema = z.union([ - z.object({ Decision: DecisionSettingsSchema }).strict(), - z.object({ Embeddings: EmbeddingParametersSchema }).strict(), - z.object({ TextGeneration: BalancerTextGenerationSettingsSchema }).strict(), -]); - -export type BalancerInferenceSettings = z.infer< - typeof BalancerInferenceSettingsSchema ->; diff --git a/paddler_client_javascript/src/schemas/InferenceNotification.ts b/paddler_client_javascript/src/schemas/InferenceNotification.ts new file mode 100644 index 000000000..3b178e0dd --- /dev/null +++ b/paddler_client_javascript/src/schemas/InferenceNotification.ts @@ -0,0 +1,17 @@ +import { z } from "zod"; + +import { InferenceModeSchema } from "./InferenceMode"; + +export const InferenceNotificationSchema = z + .object({ + Notification: z + .object({ + ClusterInferenceMode: InferenceModeSchema, + }) + .strict(), + }) + .strict(); + +export type InferenceNotification = z.infer< + typeof InferenceNotificationSchema +>["Notification"]; diff --git a/paddler_client_javascript/tests/DesiredStateServesAnotherModeError.ts b/paddler_client_javascript/tests/DesiredStateServesAnotherModeError.ts deleted file mode 100644 index 0d60ff6cb..000000000 --- a/paddler_client_javascript/tests/DesiredStateServesAnotherModeError.ts +++ /dev/null @@ -1,6 +0,0 @@ -export class DesiredStateServesAnotherModeError extends Error { - constructor() { - super("The desired state does not serve text generation"); - this.name = "DesiredStateServesAnotherModeError"; - } -} diff --git a/paddler_client_javascript/tests/SpawnedBalancer.ts b/paddler_client_javascript/tests/SpawnedBalancer.ts index 3db9c209a..1044952cd 100644 --- a/paddler_client_javascript/tests/SpawnedBalancer.ts +++ b/paddler_client_javascript/tests/SpawnedBalancer.ts @@ -1,7 +1,6 @@ import { spawn } from "node:child_process"; import { createInterface } from "node:readline"; -import type { InferenceMode } from "../src/schemas/InferenceMode"; import { BalancerAddressesSchema, type BalancerAddresses, @@ -12,18 +11,6 @@ import { SpawnedProcess } from "./SpawnedProcess"; const EPHEMERAL_LOOPBACK_ADDRESS = "127.0.0.1:0"; -const servingModeArguments: Readonly< - Record> -> = { - Decision: ["decision", "--compat-typesafe-addr", EPHEMERAL_LOOPBACK_ADDRESS], - Embeddings: ["embeddings"], - TextGeneration: [ - "text-generation", - "--compat-openai-addr", - EPHEMERAL_LOOPBACK_ADDRESS, - ], -}; - export const PADDLER_DEFAULT_BUFFERED_REQUEST_TIMEOUT_MILLISECONDS = 10_000; export class SpawnedBalancer { @@ -34,16 +21,17 @@ export class SpawnedBalancer { static async spawn({ bufferedRequestTimeoutMilliseconds, - inferenceMode, }: { bufferedRequestTimeoutMilliseconds: number; - inferenceMode: InferenceMode; }): Promise { const childProcess = spawn( paddlerBinaryPath(), [ "balancer", - ...servingModeArguments[inferenceMode], + "--compat-openai-addr", + EPHEMERAL_LOOPBACK_ADDRESS, + "--compat-typesafe-addr", + EPHEMERAL_LOOPBACK_ADDRESS, "--inference-addr", EPHEMERAL_LOOPBACK_ADDRESS, "--management-addr", diff --git a/paddler_client_javascript/tests/cluster/fetchJsonReadsTheDesiredStateTheBalancerStores.test.ts b/paddler_client_javascript/tests/cluster/fetchJsonReadsTheDesiredStateTheBalancerStores.test.ts index ed372bc3b..559a482ba 100644 --- a/paddler_client_javascript/tests/cluster/fetchJsonReadsTheDesiredStateTheBalancerStores.test.ts +++ b/paddler_client_javascript/tests/cluster/fetchJsonReadsTheDesiredStateTheBalancerStores.test.ts @@ -3,7 +3,6 @@ import { test } from "node:test"; import { assertFetchJsonReadsStoredDesiredState } from "../assertFetchJsonReadsStoredDesiredState"; import { PADDLER_DEFAULT_BUFFERED_REQUEST_TIMEOUT_MILLISECONDS } from "../SpawnedBalancer"; import { withSpawnedBalancer } from "../withSpawnedBalancer"; -import { withTextGenerationSettings } from "../withTextGenerationSettings"; test("fetchJson reads the desired state the balancer stores", async function () { await withSpawnedBalancer( @@ -15,15 +14,14 @@ test("fetchJson reads the desired state the balancer stores", async function () await assertFetchJsonReadsStoredDesiredState({ managementAddress: management, updateDesiredState(storedDesiredState) { - return withTextGenerationSettings( - storedDesiredState, - function (textGenerationSettings) { - return { - ...textGenerationSettings, - use_chat_template_override: true, - }; + return { + ...storedDesiredState, + inference_mode: "Decision", + text_generation: { + ...storedDesiredState.text_generation, + use_chat_template_override: true, }, - ); + }; }, }); }, diff --git a/paddler_client_javascript/tests/cluster/inferenceSocketClientReportsTheClusterInferenceMode.test.ts b/paddler_client_javascript/tests/cluster/inferenceSocketClientReportsTheClusterInferenceMode.test.ts new file mode 100644 index 000000000..1efc1131b --- /dev/null +++ b/paddler_client_javascript/tests/cluster/inferenceSocketClientReportsTheClusterInferenceMode.test.ts @@ -0,0 +1,63 @@ +import { deepStrictEqual, strictEqual } from "node:assert/strict"; +import { once } from "node:events"; +import { test } from "node:test"; +import { firstValueFrom, lastValueFrom, toArray } from "rxjs"; + +import { fetchJson } from "../../src/fetchJson"; +import { inferenceSocketClient } from "../../src/inferenceSocketClient"; +import { BalancerDesiredStateSchema } from "../../src/schemas/BalancerDesiredState"; +import { inferenceSocketUrl } from "../inferenceSocketUrl"; +import { putBalancerDesiredState } from "../putBalancerDesiredState"; +import { PADDLER_DEFAULT_BUFFERED_REQUEST_TIMEOUT_MILLISECONDS } from "../SpawnedBalancer"; +import { withSpawnedBalancer } from "../withSpawnedBalancer"; + +const SERVICE_UNAVAILABLE = 503; + +test("inferenceSocketClient reports the cluster inference mode", async function () { + await withSpawnedBalancer( + { + bufferedRequestTimeoutMilliseconds: + PADDLER_DEFAULT_BUFFERED_REQUEST_TIMEOUT_MILLISECONDS, + }, + async function (addresses) { + const storedDesiredState = await fetchJson({ + schema: BalancerDesiredStateSchema, + signal: new AbortController().signal, + url: `http://${addresses.management}/api/v1/balancer_desired_state`, + }); + + await putBalancerDesiredState({ + desiredState: { ...storedDesiredState, inference_mode: "Embeddings" }, + managementAddress: addresses.management, + }); + + const webSocket = new WebSocket(inferenceSocketUrl(addresses)); + + try { + const { clusterInferenceMode$, continueConversation } = + inferenceSocketClient({ webSocket }); + const firstClusterInferenceMode = firstValueFrom(clusterInferenceMode$); + + await once(webSocket, "open"); + + strictEqual(await firstClusterInferenceMode, "Embeddings"); + + const responses = await lastValueFrom( + continueConversation({ + enableThinking: false, + messages: [{ content: "Hello", role: "user" }], + }).pipe(toArray()), + ); + + deepStrictEqual( + responses.map(function ({ done, error }) { + return { done, errorCode: error?.code }; + }), + [{ done: true, errorCode: SERVICE_UNAVAILABLE }], + ); + } finally { + webSocket.close(); + } + }, + ); +}); diff --git a/paddler_client_javascript/tests/cluster/spawnedBalancerReportsAnExitBeforeAnnouncing.test.ts b/paddler_client_javascript/tests/cluster/spawnedBalancerReportsAnExitBeforeAnnouncing.test.ts index 2e9efcfe7..fae832485 100644 --- a/paddler_client_javascript/tests/cluster/spawnedBalancerReportsAnExitBeforeAnnouncing.test.ts +++ b/paddler_client_javascript/tests/cluster/spawnedBalancerReportsAnExitBeforeAnnouncing.test.ts @@ -10,7 +10,6 @@ test("spawned balancer reports an exit before announcing", async function () { await rejects( SpawnedBalancer.spawn({ bufferedRequestTimeoutMilliseconds: -1, - inferenceMode: "TextGeneration", }), function (error: unknown) { return ( diff --git a/paddler_client_javascript/tests/cluster/spawnedBalancerStoresTheDesiredStateOfItsInferenceMode.test.ts b/paddler_client_javascript/tests/cluster/spawnedBalancerStoresTheDesiredStateOfItsInferenceMode.test.ts deleted file mode 100644 index 34c093854..000000000 --- a/paddler_client_javascript/tests/cluster/spawnedBalancerStoresTheDesiredStateOfItsInferenceMode.test.ts +++ /dev/null @@ -1,29 +0,0 @@ -import { deepStrictEqual } from "node:assert/strict"; -import { test } from "node:test"; - -import { fetchJson } from "../../src/fetchJson"; -import { BalancerDesiredStateSchema } from "../../src/schemas/BalancerDesiredState"; -import { inferenceModes } from "../../src/schemas/InferenceMode"; -import { PADDLER_DEFAULT_BUFFERED_REQUEST_TIMEOUT_MILLISECONDS } from "../SpawnedBalancer"; -import { withSpawnedBalancer } from "../withSpawnedBalancer"; - -for (const inferenceMode of inferenceModes) { - test(`spawned ${inferenceMode} balancer stores the desired state of its inference mode`, async function () { - await withSpawnedBalancer( - { - bufferedRequestTimeoutMilliseconds: - PADDLER_DEFAULT_BUFFERED_REQUEST_TIMEOUT_MILLISECONDS, - inferenceMode, - }, - async function ({ management }) { - const { inference_settings } = await fetchJson({ - schema: BalancerDesiredStateSchema, - signal: new AbortController().signal, - url: `http://${management}/api/v1/balancer_desired_state`, - }); - - deepStrictEqual(Object.keys(inference_settings), [inferenceMode]); - }, - ); - }); -} diff --git a/paddler_client_javascript/tests/cluster/spawnedProcessReportsAProcessThatIgnoresTermination.test.ts b/paddler_client_javascript/tests/cluster/spawnedProcessReportsAProcessThatIgnoresTermination.test.ts index 3d5c7d7f8..5c36cc801 100644 --- a/paddler_client_javascript/tests/cluster/spawnedProcessReportsAProcessThatIgnoresTermination.test.ts +++ b/paddler_client_javascript/tests/cluster/spawnedProcessReportsAProcessThatIgnoresTermination.test.ts @@ -11,7 +11,6 @@ test("spawned process reports a process that ignores termination", async functio const balancer = await SpawnedBalancer.spawn({ bufferedRequestTimeoutMilliseconds: PADDLER_DEFAULT_BUFFERED_REQUEST_TIMEOUT_MILLISECONDS, - inferenceMode: "TextGeneration", }); process.kill(balancer.process.pid, "SIGSTOP"); diff --git a/paddler_client_javascript/tests/cluster/streamHttpNdjsonDeliversADecisionTimeoutWithoutAgents.test.ts b/paddler_client_javascript/tests/cluster/streamHttpNdjsonDeliversADecisionTimeoutWithoutAgents.test.ts index 9604cc3d1..192caada1 100644 --- a/paddler_client_javascript/tests/cluster/streamHttpNdjsonDeliversADecisionTimeoutWithoutAgents.test.ts +++ b/paddler_client_javascript/tests/cluster/streamHttpNdjsonDeliversADecisionTimeoutWithoutAgents.test.ts @@ -2,9 +2,12 @@ import { deepStrictEqual } from "node:assert/strict"; import { test } from "node:test"; import { lastValueFrom, toArray } from "rxjs"; +import { fetchJson } from "../../src/fetchJson"; +import { BalancerDesiredStateSchema } from "../../src/schemas/BalancerDesiredState"; import type { DecideParams } from "../../src/schemas/DecideParams"; import { InferenceServiceDecideResponseSchema } from "../../src/schemas/InferenceServiceDecideResponse"; import { streamHttpNdjson } from "../../src/streamHttpNdjson"; +import { putBalancerDesiredState } from "../putBalancerDesiredState"; import { SHORT_BUFFERED_REQUEST_TIMEOUT_MILLISECONDS } from "../shortBufferedRequestTimeout"; import { withSpawnedBalancer } from "../withSpawnedBalancer"; @@ -15,9 +18,19 @@ test("streamHttpNdjson delivers a decision timeout without agents", async functi { bufferedRequestTimeoutMilliseconds: SHORT_BUFFERED_REQUEST_TIMEOUT_MILLISECONDS, - inferenceMode: "Decision", }, - async function ({ inference }) { + async function ({ inference, management }) { + const storedDesiredState = await fetchJson({ + schema: BalancerDesiredStateSchema, + signal: new AbortController().signal, + url: `http://${management}/api/v1/balancer_desired_state`, + }); + + await putBalancerDesiredState({ + desiredState: { ...storedDesiredState, inference_mode: "Decision" }, + managementAddress: management, + }); + const decideParams: DecideParams = { questions: [ { id: "paid", instructions: "Was it paid?", options: ["no", "yes"] }, diff --git a/paddler_client_javascript/tests/cluster/withTextGenerationSettingsRefusesADesiredStateOfAnotherMode.test.ts b/paddler_client_javascript/tests/cluster/withTextGenerationSettingsRefusesADesiredStateOfAnotherMode.test.ts deleted file mode 100644 index a1f3f5069..000000000 --- a/paddler_client_javascript/tests/cluster/withTextGenerationSettingsRefusesADesiredStateOfAnotherMode.test.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { throws } from "node:assert/strict"; -import { test } from "node:test"; - -import { fetchJson } from "../../src/fetchJson"; -import { BalancerDesiredStateSchema } from "../../src/schemas/BalancerDesiredState"; -import { DesiredStateServesAnotherModeError } from "../DesiredStateServesAnotherModeError"; -import { PADDLER_DEFAULT_BUFFERED_REQUEST_TIMEOUT_MILLISECONDS } from "../SpawnedBalancer"; -import { withSpawnedBalancer } from "../withSpawnedBalancer"; -import { withTextGenerationSettings } from "../withTextGenerationSettings"; - -test("withTextGenerationSettings refuses a desired state of another mode", async function () { - await withSpawnedBalancer( - { - bufferedRequestTimeoutMilliseconds: - PADDLER_DEFAULT_BUFFERED_REQUEST_TIMEOUT_MILLISECONDS, - inferenceMode: "Embeddings", - }, - async function ({ management }) { - const embeddingsDesiredState = await fetchJson({ - schema: BalancerDesiredStateSchema, - signal: new AbortController().signal, - url: `http://${management}/api/v1/balancer_desired_state`, - }); - - throws(function () { - withTextGenerationSettings(embeddingsDesiredState, structuredClone); - }, DesiredStateServesAnotherModeError); - }, - ); -}); diff --git a/paddler_client_javascript/tests/qwen3DesiredState.ts b/paddler_client_javascript/tests/qwen3DesiredState.ts index 3396525cc..27f9bf6aa 100644 --- a/paddler_client_javascript/tests/qwen3DesiredState.ts +++ b/paddler_client_javascript/tests/qwen3DesiredState.ts @@ -1,29 +1,12 @@ import type { BalancerDesiredState } from "../src/schemas/BalancerDesiredState"; import { ALL_GPU_LAYERS } from "./allGpuLayers"; -import { withTextGenerationSettings } from "./withTextGenerationSettings"; export function qwen3DesiredState( storedDesiredState: BalancerDesiredState, ): BalancerDesiredState { return { - ...withTextGenerationSettings( - storedDesiredState, - function (textGenerationSettings) { - return { - ...textGenerationSettings, - sampling_parameters: { - min_p: 0, - penalty_frequency: 0, - penalty_last_n: 0, - penalty_presence: 0, - penalty_repeat: 1, - temperature: 0, - top_k: 1, - top_p: 1, - }, - }; - }, - ), + ...storedDesiredState, + inference_mode: "TextGeneration", model: { HuggingFace: { filename: "Qwen3-0.6B-Q8_0.gguf", @@ -35,5 +18,18 @@ export function qwen3DesiredState( ...storedDesiredState.model_runtime_parameters, n_gpu_layers: ALL_GPU_LAYERS, }, + text_generation: { + ...storedDesiredState.text_generation, + sampling_parameters: { + min_p: 0, + penalty_frequency: 0, + penalty_last_n: 0, + penalty_presence: 0, + penalty_repeat: 1, + temperature: 0, + top_k: 1, + top_p: 1, + }, + }, }; } diff --git a/paddler_client_javascript/tests/withSpawnedBalancer.ts b/paddler_client_javascript/tests/withSpawnedBalancer.ts index a0f95dec9..6d60c8b21 100644 --- a/paddler_client_javascript/tests/withSpawnedBalancer.ts +++ b/paddler_client_javascript/tests/withSpawnedBalancer.ts @@ -1,20 +1,16 @@ -import type { InferenceMode } from "../src/schemas/InferenceMode"; import type { BalancerAddresses } from "./BalancerAddresses"; import { SpawnedBalancer } from "./SpawnedBalancer"; export async function withSpawnedBalancer( { bufferedRequestTimeoutMilliseconds, - inferenceMode = "TextGeneration", }: { bufferedRequestTimeoutMilliseconds: number; - inferenceMode?: InferenceMode; }, body: (addresses: BalancerAddresses) => Promise, ): Promise { const balancer = await SpawnedBalancer.spawn({ bufferedRequestTimeoutMilliseconds, - inferenceMode, }); try { diff --git a/paddler_client_javascript/tests/withTextGenerationSettings.ts b/paddler_client_javascript/tests/withTextGenerationSettings.ts deleted file mode 100644 index b2b303608..000000000 --- a/paddler_client_javascript/tests/withTextGenerationSettings.ts +++ /dev/null @@ -1,24 +0,0 @@ -import type { BalancerDesiredState } from "../src/schemas/BalancerDesiredState"; -import type { BalancerTextGenerationSettings } from "../src/schemas/BalancerTextGenerationSettings"; -import { DesiredStateServesAnotherModeError } from "./DesiredStateServesAnotherModeError"; - -export function withTextGenerationSettings( - desiredState: BalancerDesiredState, - updateTextGenerationSettings: ( - this: void, - textGenerationSettings: BalancerTextGenerationSettings, - ) => BalancerTextGenerationSettings, -): BalancerDesiredState { - if (!("TextGeneration" in desiredState.inference_settings)) { - throw new DesiredStateServesAnotherModeError(); - } - - return { - ...desiredState, - inference_settings: { - TextGeneration: updateTextGenerationSettings( - desiredState.inference_settings.TextGeneration, - ), - }, - }; -} diff --git a/paddler_gui/src/model_preset.rs b/paddler_gui/src/model_preset.rs index 28e92a183..ab7b7ae41 100644 --- a/paddler_gui/src/model_preset.rs +++ b/paddler_gui/src/model_preset.rs @@ -1,9 +1,7 @@ use std::fmt; -use paddler_inference_parameters::embedding_parameters::EmbeddingParameters; use paddler_messaging::agent_desired_model::AgentDesiredModel; use paddler_messaging::balancer_desired_state::BalancerDesiredState; -use paddler_messaging::balancer_inference_settings::BalancerInferenceSettings; use paddler_messaging::balancer_text_generation_settings::BalancerTextGenerationSettings; use paddler_messaging::huggingface_model_reference::HuggingFaceModelReference; use paddler_messaging::inference_mode::InferenceMode; @@ -76,25 +74,17 @@ impl ModelPreset { #[must_use] pub fn to_balancer_desired_state(self) -> BalancerDesiredState { - let inference_settings = match self { - Self::NomicEmbedTextV1_5 => { - BalancerInferenceSettings::Embeddings(EmbeddingParameters::default()) - } - Self::Qwen3_0_6B | Self::Qwen3_5_0_8B => { - BalancerInferenceSettings::TextGeneration(BalancerTextGenerationSettings { - multimodal: MultimodalSettings { - projection: self.multimodal_projection(), - ..MultimodalSettings::default() - }, - ..BalancerTextGenerationSettings::default() - }) - } - }; - BalancerDesiredState { - inference_settings, + inference_mode: self.inference_mode(), model: AgentDesiredModel::HuggingFace(self.model()), - ..BalancerDesiredState::unconfigured(self.inference_mode()) + text_generation: BalancerTextGenerationSettings { + multimodal: MultimodalSettings { + projection: self.multimodal_projection(), + ..MultimodalSettings::default() + }, + ..BalancerTextGenerationSettings::default() + }, + ..BalancerDesiredState::default() } } } @@ -112,7 +102,6 @@ impl fmt::Display for ModelPreset { #[cfg(test)] mod tests { use paddler_messaging::agent_desired_model::AgentDesiredModel; - use paddler_messaging::balancer_inference_settings::BalancerInferenceSettings; use paddler_messaging::huggingface_model_reference::HuggingFaceModelReference; use paddler_messaging::inference_mode::InferenceMode; @@ -130,11 +119,10 @@ mod tests { revision: "main".to_owned(), }) ); - assert!(matches!( - desired_state.inference_settings, - BalancerInferenceSettings::TextGeneration(text_generation_settings) - if text_generation_settings.multimodal.projection == AgentDesiredModel::None - )); + assert_eq!( + desired_state.text_generation.multimodal.projection, + AgentDesiredModel::None + ); } #[test] @@ -142,8 +130,7 @@ mod tests { assert_eq!( ModelPreset::NomicEmbedTextV1_5 .to_balancer_desired_state() - .inference_settings - .inference_mode(), + .inference_mode, InferenceMode::Embeddings ); } diff --git a/paddler_gui/src/running_balancer_data.rs b/paddler_gui/src/running_balancer_data.rs index 1111f9d09..ea7ba9afa 100644 --- a/paddler_gui/src/running_balancer_data.rs +++ b/paddler_gui/src/running_balancer_data.rs @@ -45,7 +45,6 @@ mod tests { use paddler_balancer::balancer_addresses::BalancerAddresses; use paddler_balancer::balancer_applicable_state::BalancerApplicableState; use paddler_messaging::balancer_desired_state::BalancerDesiredState; - use paddler_messaging::inference_mode::InferenceMode; use super::RunningBalancerData; use crate::running_balancer_action::RunningBalancerAction; @@ -65,11 +64,9 @@ mod tests { snapshot: Box::new(RunningBalancerSnapshot { agent_snapshots: Vec::new(), balancer_applicable_state: BalancerApplicableState::from( - BalancerDesiredState::unconfigured(InferenceMode::TextGeneration), - ), - balancer_desired_state: BalancerDesiredState::unconfigured( - InferenceMode::TextGeneration, + BalancerDesiredState::default(), ), + balancer_desired_state: BalancerDesiredState::default(), }), stopping: false, } diff --git a/paddler_gui/src/running_balancer_snapshot.rs b/paddler_gui/src/running_balancer_snapshot.rs index 9e95d5b62..d61ebed40 100644 --- a/paddler_gui/src/running_balancer_snapshot.rs +++ b/paddler_gui/src/running_balancer_snapshot.rs @@ -42,7 +42,6 @@ mod tests { use paddler_balancer::balancer_applicable_state_holder::BalancerApplicableStateHolder; use paddler_messaging::agent_desired_model::AgentDesiredModel; use paddler_messaging::balancer_desired_state::BalancerDesiredState; - use paddler_messaging::inference_mode::InferenceMode; use super::RunningBalancerSnapshot; @@ -52,16 +51,16 @@ mod tests { let requested_model = AgentDesiredModel::LocalToAgent("requested_model".to_owned()); let snapshot = RunningBalancerSnapshot::build( - &AgentControllerPool::new(InferenceMode::TextGeneration), + &AgentControllerPool::default(), &BalancerApplicableStateHolder::new(BalancerApplicableState::from( BalancerDesiredState { model: applied_model.clone(), - ..BalancerDesiredState::unconfigured(InferenceMode::TextGeneration) + ..BalancerDesiredState::default() }, )), BalancerDesiredState { model: requested_model.clone(), - ..BalancerDesiredState::unconfigured(InferenceMode::TextGeneration) + ..BalancerDesiredState::default() }, ); diff --git a/paddler_gui/src/start_balancer_form_data.rs b/paddler_gui/src/start_balancer_form_data.rs index 2ef7e192a..ff242990e 100644 --- a/paddler_gui/src/start_balancer_form_data.rs +++ b/paddler_gui/src/start_balancer_form_data.rs @@ -8,7 +8,6 @@ use paddler_balancer::resolved_socket_addr::ResolvedSocketAddr; use paddler_balancer::web_admin_panel_service::configuration::Configuration as WebAdminPanelServiceConfiguration; use paddler_balancer_runner::balancer_defaults::BalancerDefaults; use paddler_balancer_runner::balancer_runner_config::BalancerRunnerConfig; -use paddler_balancer_runner::balancer_serving_mode::BalancerServingMode; use paddler_messaging::balancer_desired_state::BalancerDesiredState; use paddler_messaging::inference_mode::InferenceMode; use paddler_state_database::state_database_type::StateDatabaseType; @@ -47,18 +46,11 @@ fn balancer_runner_config( cors_allowed_hosts: vec![], }, max_buffered_requests: BalancerDefaults::MAX_BUFFERED_REQUESTS, - serving_mode: match desired_state.inference_settings.inference_mode() { - InferenceMode::Decision => BalancerServingMode::Decision { - typesafe_service_configuration: None, - }, - InferenceMode::Embeddings => BalancerServingMode::Embeddings, - InferenceMode::TextGeneration => BalancerServingMode::TextGeneration { - openai_service_configuration: None, - }, - }, - state_database_type: StateDatabaseType::MemoryStartingWith(Box::new(desired_state)), + openai_service_configuration: None, + state_database_type: StateDatabaseType::Memory(Box::new(desired_state)), statsd_prefix: BalancerDefaults::STATSD_PREFIX.to_owned(), statsd_service_configuration: None, + typesafe_service_configuration: None, #[cfg(feature = "web_admin_panel")] web_admin_panel_service_configuration: web_admin_panel_addr .map(|addr| WebAdminPanelServiceConfiguration { addr }), @@ -115,7 +107,7 @@ impl StartBalancerFormData { balancer_address_error: None, inference_address: suggested_address_on(BalancerDefaults::INFERENCE_PORT), inference_address_error: None, - inference_mode: InferenceMode::TextGeneration, + inference_mode: InferenceMode::default(), launch: BalancerLaunch::NotRequested, model_error: None, selected_model: None, @@ -184,7 +176,10 @@ impl StartBalancerFormData { fn desired_state(&self) -> Result { if self.add_model_later { - return Ok(BalancerDesiredState::unconfigured(self.inference_mode)); + return Ok(BalancerDesiredState { + inference_mode: self.inference_mode, + ..BalancerDesiredState::default() + }); } self.selected_model @@ -232,7 +227,6 @@ mod tests { use paddler_messaging::agent_desired_model::AgentDesiredModel; use paddler_messaging::balancer_desired_state::BalancerDesiredState; - use paddler_messaging::balancer_inference_settings::BalancerInferenceSettings; use paddler_messaging::inference_mode::InferenceMode; use paddler_state_database::state_database_type::StateDatabaseType; use paddler_test_cluster_harness::ephemeral_loopback_addr::EPHEMERAL_LOOPBACK_ADDR; @@ -296,9 +290,9 @@ mod tests { StartBalancerFormAction::StartBalancer(runner_config) if matches!( &runner_config.state_database_type, - StateDatabaseType::MemoryStartingWith(desired_state) + StateDatabaseType::Memory(desired_state) if **desired_state - == BalancerDesiredState::unconfigured(InferenceMode::TextGeneration) + == BalancerDesiredState::default() ) )); } @@ -316,7 +310,11 @@ mod tests { assert!(matches!( form.update(StartBalancerFormMessage::Confirm), StartBalancerFormAction::StartBalancer(runner_config) - if runner_config.serving_mode.inference_mode() == inference_mode + if matches!( + &runner_config.state_database_type, + StateDatabaseType::Memory(desired_state) + if desired_state.inference_mode == inference_mode + ) )); } } @@ -391,14 +389,10 @@ mod tests { StartBalancerFormAction::StartBalancer(runner_config) if matches!( &runner_config.state_database_type, - StateDatabaseType::MemoryStartingWith(desired_state) + StateDatabaseType::Memory(desired_state) if desired_state.model == expected_model - && matches!( - &desired_state.inference_settings, - BalancerInferenceSettings::TextGeneration(text_generation_settings) - if text_generation_settings.multimodal.projection - == expected_multimodal_projection - ) + && desired_state.text_generation.multimodal.projection + == expected_multimodal_projection ) )); } diff --git a/paddler_gui_tests/tests/a_cluster_that_fails_while_running_shows_why_on_the_home_screen.rs b/paddler_gui_tests/tests/a_cluster_that_fails_while_running_shows_why_on_the_home_screen.rs index be891ff8e..925c480db 100644 --- a/paddler_gui_tests/tests/a_cluster_that_fails_while_running_shows_why_on_the_home_screen.rs +++ b/paddler_gui_tests/tests/a_cluster_that_fails_while_running_shows_why_on_the_home_screen.rs @@ -10,7 +10,6 @@ use paddler_gui::running_balancer_snapshot::RunningBalancerSnapshot; use paddler_gui_tests::app_driver::AppDriver; use paddler_gui_tests::loopback_balancer_addresses::LOOPBACK_BALANCER_ADDRESSES; use paddler_messaging::balancer_desired_state::BalancerDesiredState; -use paddler_messaging::inference_mode::InferenceMode; use paddler_service_thread::service_thread_error::ServiceThreadError; #[tokio::test(flavor = "multi_thread")] @@ -25,11 +24,9 @@ async fn a_cluster_that_fails_while_running_shows_why_on_the_home_screen() { snapshot: Box::new(RunningBalancerSnapshot { agent_snapshots: Vec::new(), balancer_applicable_state: BalancerApplicableState::from( - BalancerDesiredState::unconfigured(InferenceMode::TextGeneration), - ), - balancer_desired_state: BalancerDesiredState::unconfigured( - InferenceMode::TextGeneration, + BalancerDesiredState::default(), ), + balancer_desired_state: BalancerDesiredState::default(), }), }, ] { diff --git a/paddler_gui_tests/tests/a_running_cluster_lists_its_agents.rs b/paddler_gui_tests/tests/a_running_cluster_lists_its_agents.rs index 874155e05..d1b6c3d81 100644 --- a/paddler_gui_tests/tests/a_running_cluster_lists_its_agents.rs +++ b/paddler_gui_tests/tests/a_running_cluster_lists_its_agents.rs @@ -34,11 +34,9 @@ fn a_running_cluster_lists_its_agents() { }, }], balancer_applicable_state: BalancerApplicableState::from( - BalancerDesiredState::unconfigured(InferenceMode::TextGeneration), - ), - balancer_desired_state: BalancerDesiredState::unconfigured( - InferenceMode::TextGeneration, + BalancerDesiredState::default(), ), + balancer_desired_state: BalancerDesiredState::default(), }), stopping: false, }; diff --git a/paddler_gui_tests/tests/a_running_cluster_shows_its_configured_and_applied_models.rs b/paddler_gui_tests/tests/a_running_cluster_shows_its_configured_and_applied_models.rs index f43658b1f..7fe96dd6a 100644 --- a/paddler_gui_tests/tests/a_running_cluster_shows_its_configured_and_applied_models.rs +++ b/paddler_gui_tests/tests/a_running_cluster_shows_its_configured_and_applied_models.rs @@ -7,7 +7,6 @@ use paddler_gui::running_balancer_snapshot::RunningBalancerSnapshot; use paddler_gui_tests::loopback_balancer_addresses::LOOPBACK_BALANCER_ADDRESSES; use paddler_messaging::agent_desired_model::AgentDesiredModel; use paddler_messaging::balancer_desired_state::BalancerDesiredState; -use paddler_messaging::inference_mode::InferenceMode; #[test] fn a_running_cluster_shows_its_configured_and_applied_models() { @@ -17,11 +16,11 @@ fn a_running_cluster_shows_its_configured_and_applied_models() { snapshot: Box::new(RunningBalancerSnapshot { agent_snapshots: Vec::new(), balancer_applicable_state: BalancerApplicableState::from( - BalancerDesiredState::unconfigured(InferenceMode::TextGeneration), + BalancerDesiredState::default(), ), balancer_desired_state: BalancerDesiredState { model: AgentDesiredModel::LocalToAgent("/models/qwen3.gguf".to_owned()), - ..BalancerDesiredState::unconfigured(InferenceMode::TextGeneration) + ..BalancerDesiredState::default() }, }), stopping: false, diff --git a/paddler_gui_tests/tests/a_running_cluster_without_agents_waits_for_them.rs b/paddler_gui_tests/tests/a_running_cluster_without_agents_waits_for_them.rs index 08f76a65a..f6c56a293 100644 --- a/paddler_gui_tests/tests/a_running_cluster_without_agents_waits_for_them.rs +++ b/paddler_gui_tests/tests/a_running_cluster_without_agents_waits_for_them.rs @@ -6,7 +6,6 @@ use paddler_gui::running_balancer_data::RunningBalancerData; use paddler_gui::running_balancer_snapshot::RunningBalancerSnapshot; use paddler_gui_tests::loopback_balancer_addresses::LOOPBACK_BALANCER_ADDRESSES; use paddler_messaging::balancer_desired_state::BalancerDesiredState; -use paddler_messaging::inference_mode::InferenceMode; #[test] fn a_running_cluster_without_agents_waits_for_them() { @@ -16,11 +15,9 @@ fn a_running_cluster_without_agents_waits_for_them() { snapshot: Box::new(RunningBalancerSnapshot { agent_snapshots: Vec::new(), balancer_applicable_state: BalancerApplicableState::from( - BalancerDesiredState::unconfigured(InferenceMode::TextGeneration), - ), - balancer_desired_state: BalancerDesiredState::unconfigured( - InferenceMode::TextGeneration, + BalancerDesiredState::default(), ), + balancer_desired_state: BalancerDesiredState::default(), }), stopping: false, }; diff --git a/paddler_messaging/src/agent_desired_state.rs b/paddler_messaging/src/agent_desired_state.rs index 202b00922..ebded1c70 100644 --- a/paddler_messaging/src/agent_desired_state.rs +++ b/paddler_messaging/src/agent_desired_state.rs @@ -5,7 +5,9 @@ use paddler_inference_parameters::model_runtime_parameters::ModelRuntimeParamete use crate::agent_desired_model::AgentDesiredModel; use crate::agent_inference_settings::AgentInferenceSettings; +use crate::agent_text_generation_settings::AgentTextGenerationSettings; use crate::balancer_desired_state::BalancerDesiredState; +use crate::inference_mode::InferenceMode; #[derive(Clone, Debug, Deserialize, PartialEq, Serialize)] #[serde(deny_unknown_fields)] @@ -18,15 +20,91 @@ pub struct AgentDesiredState { impl From for AgentDesiredState { fn from( BalancerDesiredState { - inference_settings, + decision, + embeddings, + inference_mode, model, model_runtime_parameters, + text_generation, }: BalancerDesiredState, ) -> Self { Self { - inference_settings: AgentInferenceSettings::from(inference_settings), + inference_settings: match inference_mode { + InferenceMode::Decision => AgentInferenceSettings::Decision(decision), + InferenceMode::Embeddings => AgentInferenceSettings::Embeddings(embeddings), + InferenceMode::TextGeneration => AgentInferenceSettings::TextGeneration( + AgentTextGenerationSettings::from(text_generation), + ), + }, model, model_runtime_parameters, } } } + +#[cfg(test)] +mod tests { + use std::num::NonZeroUsize; + + use paddler_inference_parameters::embedding_parameters::EmbeddingParameters; + use paddler_inference_parameters::pooling_type::PoolingType; + use paddler_inference_parameters::sampling_parameters::SamplingParameters; + + use super::AgentDesiredState; + use crate::agent_desired_model::AgentDesiredModel; + use crate::agent_inference_settings::AgentInferenceSettings; + use crate::agent_text_generation_settings::AgentTextGenerationSettings; + use crate::balancer_desired_state::BalancerDesiredState; + use crate::balancer_text_generation_settings::BalancerTextGenerationSettings; + use crate::decision_settings::DecisionSettings; + use crate::inference_mode::InferenceMode; + + fn decision_settings() -> DecisionSettings { + DecisionSettings { + pointer_head: AgentDesiredModel::LocalToAgent("pointer_head.gguf".to_owned()), + } + } + + fn embedding_parameters() -> EmbeddingParameters { + EmbeddingParameters { + embedding_batch_size: NonZeroUsize::MIN, + pooling_type: PoolingType::Mean, + } + } + + fn text_generation_settings() -> BalancerTextGenerationSettings { + BalancerTextGenerationSettings { + sampling_parameters: SamplingParameters::deterministic(), + ..BalancerTextGenerationSettings::default() + } + } + + fn agent_settings_while_serving(inference_mode: InferenceMode) -> AgentInferenceSettings { + AgentDesiredState::from(BalancerDesiredState { + decision: decision_settings(), + embeddings: embedding_parameters(), + inference_mode, + text_generation: text_generation_settings(), + ..BalancerDesiredState::default() + }) + .inference_settings + } + + #[test] + fn agents_receive_only_the_settings_of_the_active_inference_mode() { + assert_eq!( + agent_settings_while_serving(InferenceMode::Decision), + AgentInferenceSettings::Decision(decision_settings()) + ); + assert_eq!( + agent_settings_while_serving(InferenceMode::Embeddings), + AgentInferenceSettings::Embeddings(embedding_parameters()) + ); + assert_eq!( + agent_settings_while_serving(InferenceMode::TextGeneration), + AgentInferenceSettings::TextGeneration(AgentTextGenerationSettings::from( + text_generation_settings() + )) + ); + } +} diff --git a/paddler_messaging/src/agent_inference_settings.rs b/paddler_messaging/src/agent_inference_settings.rs index 512c45448..4bf0ade4c 100644 --- a/paddler_messaging/src/agent_inference_settings.rs +++ b/paddler_messaging/src/agent_inference_settings.rs @@ -4,7 +4,6 @@ use serde::Serialize; use paddler_inference_parameters::embedding_parameters::EmbeddingParameters; use crate::agent_text_generation_settings::AgentTextGenerationSettings; -use crate::balancer_inference_settings::BalancerInferenceSettings; use crate::decision_settings::DecisionSettings; use crate::inference_mode::InferenceMode; @@ -26,87 +25,3 @@ impl AgentInferenceSettings { } } } - -impl From for AgentInferenceSettings { - fn from(balancer_inference_settings: BalancerInferenceSettings) -> Self { - match balancer_inference_settings { - BalancerInferenceSettings::Decision(decision_settings) => { - Self::Decision(decision_settings) - } - BalancerInferenceSettings::Embeddings(embedding_parameters) => { - Self::Embeddings(embedding_parameters) - } - BalancerInferenceSettings::TextGeneration(text_generation_settings) => { - Self::TextGeneration(AgentTextGenerationSettings { - chat_template_source: text_generation_settings.chat_template_source(), - multimodal: text_generation_settings.multimodal.clone(), - sampling_parameters: text_generation_settings.sampling_parameters, - }) - } - } - } -} - -#[cfg(test)] -mod tests { - use paddler_inference_parameters::embedding_parameters::EmbeddingParameters; - use paddler_inference_parameters::sampling_parameters::SamplingParameters; - - use super::AgentInferenceSettings; - use crate::agent_text_generation_settings::AgentTextGenerationSettings; - use crate::balancer_inference_settings::BalancerInferenceSettings; - use crate::balancer_text_generation_settings::BalancerTextGenerationSettings; - use crate::chat_template::ChatTemplate; - use crate::chat_template_source::ChatTemplateSource; - use crate::inference_mode::InferenceMode; - - #[test] - fn embedding_parameters_reach_the_agent_unchanged() { - assert_eq!( - AgentInferenceSettings::from(BalancerInferenceSettings::Embeddings( - EmbeddingParameters::default() - )), - AgentInferenceSettings::Embeddings(EmbeddingParameters::default()) - ); - } - - #[test] - fn text_generation_settings_reach_the_agent_with_the_resolved_chat_template() { - let chat_template = ChatTemplate { - content: "{{ messages }}".to_owned(), - }; - - assert_eq!( - AgentInferenceSettings::from(BalancerInferenceSettings::TextGeneration( - BalancerTextGenerationSettings { - chat_template_override: Some(chat_template.clone()), - sampling_parameters: SamplingParameters::deterministic(), - use_chat_template_override: true, - ..BalancerTextGenerationSettings::default() - } - )), - AgentInferenceSettings::TextGeneration(AgentTextGenerationSettings { - chat_template_source: ChatTemplateSource::Override(chat_template), - sampling_parameters: SamplingParameters::deterministic(), - ..AgentTextGenerationSettings::default() - }) - ); - } - - #[test] - fn agent_settings_serve_the_mode_of_the_balancer_settings() { - for inference_mode in [ - InferenceMode::Decision, - InferenceMode::Embeddings, - InferenceMode::TextGeneration, - ] { - assert_eq!( - AgentInferenceSettings::from(BalancerInferenceSettings::unconfigured( - inference_mode - )) - .inference_mode(), - inference_mode - ); - } - } -} diff --git a/paddler_messaging/src/agent_runtime_status.rs b/paddler_messaging/src/agent_runtime_status.rs index 6bd436c34..0c7716d2e 100644 --- a/paddler_messaging/src/agent_runtime_status.rs +++ b/paddler_messaging/src/agent_runtime_status.rs @@ -22,14 +22,6 @@ impl AgentRuntimeStatus { Self::Serving { slots_total, .. } => *slots_total, } } - - #[must_use] - pub fn serves(&self, served_inference_mode: InferenceMode) -> bool { - match self { - Self::Idle => false, - Self::Serving { inference_mode, .. } => *inference_mode == served_inference_mode, - } - } } #[cfg(test)] @@ -51,15 +43,4 @@ mod tests { fn a_serving_agent_has_its_slots() { assert_eq!(SERVING_EMBEDDINGS.slots_total(), 3); } - - #[test] - fn an_idle_agent_serves_no_mode() { - assert!(!AgentRuntimeStatus::Idle.serves(InferenceMode::Embeddings)); - } - - #[test] - fn a_serving_agent_serves_only_its_own_mode() { - assert!(SERVING_EMBEDDINGS.serves(InferenceMode::Embeddings)); - assert!(!SERVING_EMBEDDINGS.serves(InferenceMode::TextGeneration)); - } } diff --git a/paddler_messaging/src/agent_text_generation_settings.rs b/paddler_messaging/src/agent_text_generation_settings.rs index 021e8ae42..3beac5622 100644 --- a/paddler_messaging/src/agent_text_generation_settings.rs +++ b/paddler_messaging/src/agent_text_generation_settings.rs @@ -3,6 +3,7 @@ use serde::Serialize; use paddler_inference_parameters::sampling_parameters::SamplingParameters; +use crate::balancer_text_generation_settings::BalancerTextGenerationSettings; use crate::chat_template_source::ChatTemplateSource; use crate::multimodal_settings::MultimodalSettings; @@ -13,3 +14,44 @@ pub struct AgentTextGenerationSettings { pub multimodal: MultimodalSettings, pub sampling_parameters: SamplingParameters, } + +impl From for AgentTextGenerationSettings { + fn from(balancer_text_generation_settings: BalancerTextGenerationSettings) -> Self { + Self { + chat_template_source: balancer_text_generation_settings.chat_template_source(), + multimodal: balancer_text_generation_settings.multimodal, + sampling_parameters: balancer_text_generation_settings.sampling_parameters, + } + } +} + +#[cfg(test)] +mod tests { + use paddler_inference_parameters::sampling_parameters::SamplingParameters; + + use super::AgentTextGenerationSettings; + use crate::balancer_text_generation_settings::BalancerTextGenerationSettings; + use crate::chat_template::ChatTemplate; + use crate::chat_template_source::ChatTemplateSource; + + #[test] + fn text_generation_settings_reach_the_agent_with_the_resolved_chat_template() { + let chat_template = ChatTemplate { + content: "{{ messages }}".to_owned(), + }; + + assert_eq!( + AgentTextGenerationSettings::from(BalancerTextGenerationSettings { + chat_template_override: Some(chat_template.clone()), + sampling_parameters: SamplingParameters::deterministic(), + use_chat_template_override: true, + ..BalancerTextGenerationSettings::default() + }), + AgentTextGenerationSettings { + chat_template_source: ChatTemplateSource::Override(chat_template), + sampling_parameters: SamplingParameters::deterministic(), + ..AgentTextGenerationSettings::default() + } + ); + } +} diff --git a/paddler_messaging/src/balancer_desired_state.rs b/paddler_messaging/src/balancer_desired_state.rs index 01e528495..173ab13e2 100644 --- a/paddler_messaging/src/balancer_desired_state.rs +++ b/paddler_messaging/src/balancer_desired_state.rs @@ -1,27 +1,21 @@ use serde::Deserialize; use serde::Serialize; +use paddler_inference_parameters::embedding_parameters::EmbeddingParameters; use paddler_inference_parameters::model_runtime_parameters::ModelRuntimeParameters; use crate::agent_desired_model::AgentDesiredModel; -use crate::balancer_inference_settings::BalancerInferenceSettings; +use crate::balancer_text_generation_settings::BalancerTextGenerationSettings; +use crate::decision_settings::DecisionSettings; use crate::inference_mode::InferenceMode; -#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)] +#[derive(Clone, Debug, Default, Deserialize, PartialEq, Serialize)] #[serde(deny_unknown_fields)] pub struct BalancerDesiredState { - pub inference_settings: BalancerInferenceSettings, + pub decision: DecisionSettings, + pub embeddings: EmbeddingParameters, + pub inference_mode: InferenceMode, pub model: AgentDesiredModel, pub model_runtime_parameters: ModelRuntimeParameters, -} - -impl BalancerDesiredState { - #[must_use] - pub fn unconfigured(inference_mode: InferenceMode) -> Self { - Self { - inference_settings: BalancerInferenceSettings::unconfigured(inference_mode), - model: AgentDesiredModel::None, - model_runtime_parameters: ModelRuntimeParameters::default(), - } - } + pub text_generation: BalancerTextGenerationSettings, } diff --git a/paddler_messaging/src/balancer_inference_settings.rs b/paddler_messaging/src/balancer_inference_settings.rs deleted file mode 100644 index 618525c57..000000000 --- a/paddler_messaging/src/balancer_inference_settings.rs +++ /dev/null @@ -1,58 +0,0 @@ -use serde::Deserialize; -use serde::Serialize; - -use paddler_inference_parameters::embedding_parameters::EmbeddingParameters; - -use crate::balancer_text_generation_settings::BalancerTextGenerationSettings; -use crate::decision_settings::DecisionSettings; -use crate::inference_mode::InferenceMode; - -#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)] -#[serde(deny_unknown_fields)] -pub enum BalancerInferenceSettings { - Decision(DecisionSettings), - Embeddings(EmbeddingParameters), - TextGeneration(BalancerTextGenerationSettings), -} - -impl BalancerInferenceSettings { - #[must_use] - pub fn unconfigured(inference_mode: InferenceMode) -> Self { - match inference_mode { - InferenceMode::Decision => Self::Decision(DecisionSettings::default()), - InferenceMode::Embeddings => Self::Embeddings(EmbeddingParameters::default()), - InferenceMode::TextGeneration => { - Self::TextGeneration(BalancerTextGenerationSettings::default()) - } - } - } - - #[must_use] - pub const fn inference_mode(&self) -> InferenceMode { - match self { - Self::Decision(_) => InferenceMode::Decision, - Self::Embeddings(_) => InferenceMode::Embeddings, - Self::TextGeneration(_) => InferenceMode::TextGeneration, - } - } -} - -#[cfg(test)] -mod tests { - use super::BalancerInferenceSettings; - use crate::inference_mode::InferenceMode; - - #[test] - fn unconfigured_settings_serve_the_requested_mode() { - for inference_mode in [ - InferenceMode::Decision, - InferenceMode::Embeddings, - InferenceMode::TextGeneration, - ] { - assert_eq!( - BalancerInferenceSettings::unconfigured(inference_mode).inference_mode(), - inference_mode - ); - } - } -} diff --git a/paddler_messaging/src/inference_client/message.rs b/paddler_messaging/src/inference_client/message.rs index 1c338222f..2c801d266 100644 --- a/paddler_messaging/src/inference_client/message.rs +++ b/paddler_messaging/src/inference_client/message.rs @@ -1,6 +1,7 @@ use serde::Deserialize; use serde::Serialize; +use super::notification::Notification; use super::response::Response; use crate::jsonrpc::error::Error; use crate::jsonrpc::error_envelope::ErrorEnvelope; @@ -11,6 +12,7 @@ use crate::rpc_message::RpcMessage; #[serde(deny_unknown_fields)] pub enum Message { Error(ErrorEnvelope), + Notification(Notification), Response(ResponseEnvelope), } diff --git a/paddler_messaging/src/inference_client/mod.rs b/paddler_messaging/src/inference_client/mod.rs index 3479387e0..9c7e6cab5 100644 --- a/paddler_messaging/src/inference_client/mod.rs +++ b/paddler_messaging/src/inference_client/mod.rs @@ -1,3 +1,4 @@ pub mod identified_message; pub mod message; +pub mod notification; pub mod response; diff --git a/paddler_messaging/src/inference_client/notification.rs b/paddler_messaging/src/inference_client/notification.rs new file mode 100644 index 000000000..8f86f291e --- /dev/null +++ b/paddler_messaging/src/inference_client/notification.rs @@ -0,0 +1,10 @@ +use serde::Deserialize; +use serde::Serialize; + +use crate::inference_mode::InferenceMode; + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(deny_unknown_fields)] +pub enum Notification { + ClusterInferenceMode(InferenceMode), +} diff --git a/paddler_messaging/src/inference_mode.rs b/paddler_messaging/src/inference_mode.rs index 58f4c1745..df748ba07 100644 --- a/paddler_messaging/src/inference_mode.rs +++ b/paddler_messaging/src/inference_mode.rs @@ -1,10 +1,11 @@ use serde::Deserialize; use serde::Serialize; -#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[derive(Clone, Copy, Debug, Default, Deserialize, Eq, PartialEq, Serialize)] #[serde(deny_unknown_fields)] pub enum InferenceMode { Decision, Embeddings, + #[default] TextGeneration, } diff --git a/paddler_messaging/src/lib.rs b/paddler_messaging/src/lib.rs index e106e3c30..a40493608 100644 --- a/paddler_messaging/src/lib.rs +++ b/paddler_messaging/src/lib.rs @@ -13,7 +13,6 @@ pub mod api_path; pub mod atomic_value; pub mod balancer_connection; pub mod balancer_desired_state; -pub mod balancer_inference_settings; pub mod balancer_text_generation_settings; pub mod buffered_request_manager_snapshot; pub mod chat_template; diff --git a/paddler_messaging/src/request_params/generate_embedding_batch_params/mod.rs b/paddler_messaging/src/request_params/generate_embedding_batch_params/mod.rs index 81b9eb70f..e4b835465 100644 --- a/paddler_messaging/src/request_params/generate_embedding_batch_params/mod.rs +++ b/paddler_messaging/src/request_params/generate_embedding_batch_params/mod.rs @@ -17,7 +17,7 @@ impl GenerateEmbeddingBatchParams { #[must_use] pub fn chunk_evenly_with_cap( self, - agent_count: NonZeroUsize, + agent_count: usize, max_documents_per_chunk: NonZeroUsize, ) -> Vec { let Self { @@ -31,7 +31,7 @@ impl GenerateEmbeddingBatchParams { } let chunks_to_honor_cap = document_count.div_ceil(max_documents_per_chunk.get()); - let chunk_count = document_count.min(agent_count.get().max(chunks_to_honor_cap)); + 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; @@ -63,10 +63,6 @@ mod tests { use crate::embedding_input_document::EmbeddingInputDocument; use crate::embedding_normalization_method::EmbeddingNormalizationMethod; - fn agents(agent_count: usize) -> NonZeroUsize { - NonZeroUsize::new(agent_count).expect("an agent count must be non-zero") - } - fn chunk_cap(max_documents_per_chunk: usize) -> NonZeroUsize { NonZeroUsize::new(max_documents_per_chunk).expect("a chunk cap must be non-zero") } @@ -95,7 +91,7 @@ mod tests { fn chunk_evenly_with_cap_empty_input() { let params = make_params(vec![]); - let sub_batches = params.chunk_evenly_with_cap(agents(4), chunk_cap(256)); + let sub_batches = params.chunk_evenly_with_cap(4, chunk_cap(256)); assert!(sub_batches.is_empty()); } @@ -104,7 +100,7 @@ mod tests { 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(agents(1), chunk_cap(256)); + let sub_batches = params.chunk_evenly_with_cap(1, chunk_cap(256)); assert_eq!(sub_batches.len(), 1); assert_eq!(sub_batches[0].input_batch.len(), 1); @@ -115,7 +111,7 @@ mod tests { 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(agents(5), chunk_cap(256)); + let sub_batches = params.chunk_evenly_with_cap(5, chunk_cap(256)); assert_eq!(sub_batches.len(), 1); assert_eq!(sub_batches[0].input_batch.len(), 1); @@ -126,7 +122,7 @@ mod tests { 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(agents(5), chunk_cap(256)); + let sub_batches = params.chunk_evenly_with_cap(5, chunk_cap(256)); assert_eq!(sub_batches.len(), 3); for sub_batch in &sub_batches { @@ -138,7 +134,7 @@ mod tests { fn chunk_evenly_with_cap_below_cap_splits_per_agent() { let params = make_params(make_docs(4)); - let sub_batches = params.chunk_evenly_with_cap(agents(4), chunk_cap(256)); + let sub_batches = params.chunk_evenly_with_cap(4, chunk_cap(256)); assert_eq!(sub_batches.len(), 4); for sub_batch in &sub_batches { @@ -150,7 +146,7 @@ mod tests { fn chunk_evenly_with_cap_below_cap_uneven_split() { let params = make_params(make_docs(11)); - let sub_batches = params.chunk_evenly_with_cap(agents(4), chunk_cap(256)); + let sub_batches = params.chunk_evenly_with_cap(4, chunk_cap(256)); assert_eq!(sub_batches.len(), 4); assert_eq!(sub_batches[0].input_batch.len(), 3); @@ -163,7 +159,7 @@ mod tests { fn chunk_evenly_with_cap_splits_a_batch_below_the_cap_across_every_agent() { let params = make_params(make_docs(80)); - let sub_batches = params.chunk_evenly_with_cap(agents(4), chunk_cap(100)); + let sub_batches = params.chunk_evenly_with_cap(4, chunk_cap(100)); assert_eq!(sub_batches.len(), 4); for sub_batch in &sub_batches { @@ -175,7 +171,7 @@ mod tests { fn chunk_evenly_with_cap_splits_a_batch_above_the_cap_into_capped_chunks() { let params = make_params(make_docs(1000)); - let sub_batches = params.chunk_evenly_with_cap(agents(4), chunk_cap(100)); + let sub_batches = params.chunk_evenly_with_cap(4, chunk_cap(100)); assert_eq!(sub_batches.len(), 10); for sub_batch in &sub_batches { @@ -187,7 +183,7 @@ mod tests { 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(agents(4), chunk_cap(256)); + let sub_batches = params.chunk_evenly_with_cap(4, chunk_cap(256)); assert_eq!(sub_batches.len(), 4); for sub_batch in &sub_batches { @@ -199,7 +195,7 @@ mod tests { fn chunk_evenly_with_cap_above_cap_boundary_creates_extra_chunks() { let params = make_params(make_docs(2000)); - let sub_batches = params.chunk_evenly_with_cap(agents(4), chunk_cap(256)); + let sub_batches = params.chunk_evenly_with_cap(4, chunk_cap(256)); assert_eq!(sub_batches.len(), 8); for sub_batch in &sub_batches { @@ -211,7 +207,7 @@ mod tests { fn chunk_evenly_with_cap_far_above_cap_distributes_evenly() { let params = make_params(make_docs(1100)); - let sub_batches = params.chunk_evenly_with_cap(agents(4), chunk_cap(256)); + let sub_batches = params.chunk_evenly_with_cap(4, chunk_cap(256)); assert_eq!(sub_batches.len(), 5); for sub_batch in &sub_batches { @@ -223,7 +219,7 @@ mod tests { fn chunk_evenly_with_cap_extreme_large_n_small_cap() { let params = make_params(make_docs(10_000)); - let sub_batches = params.chunk_evenly_with_cap(agents(4), chunk_cap(1)); + let sub_batches = params.chunk_evenly_with_cap(4, chunk_cap(1)); assert_eq!(sub_batches.len(), 10_000); for sub_batch in &sub_batches { @@ -235,7 +231,7 @@ mod tests { fn chunk_evenly_with_cap_extreme_one_doc_per_chunk() { let params = make_params(make_docs(100)); - let sub_batches = params.chunk_evenly_with_cap(agents(100), chunk_cap(256)); + let sub_batches = params.chunk_evenly_with_cap(100, chunk_cap(256)); assert_eq!(sub_batches.len(), 100); for sub_batch in &sub_batches { @@ -246,7 +242,7 @@ mod tests { #[test] 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 = (1..=8).collect(); + let agent_counts: Vec = (0..=8).collect(); let caps: Vec = vec![1, 2, 4, 100, 256]; for &document_count in &document_counts { @@ -254,8 +250,7 @@ mod tests { for &cap in &caps { let params = make_params(make_docs(document_count)); - let sub_batches = - params.chunk_evenly_with_cap(agents(agent_count), chunk_cap(cap)); + let sub_batches = params.chunk_evenly_with_cap(agent_count, chunk_cap(cap)); let total_documents: usize = sub_batches.iter().map(|sub| sub.input_batch.len()).sum(); @@ -305,6 +300,19 @@ mod tests { } } + #[test] + fn chunk_evenly_with_cap_without_agents_splits_only_to_honor_the_cap() { + let params = make_params(make_docs(600)); + + let sub_batches = params.chunk_evenly_with_cap(0, chunk_cap(256)); + + let sizes: Vec = sub_batches + .iter() + .map(|sub_batch| sub_batch.input_batch.len()) + .collect(); + assert_eq!(sizes, vec![200, 200, 200]); + } + #[test] fn chunk_evenly_with_cap_preserves_normalization_method() { let params = GenerateEmbeddingBatchParams { @@ -312,7 +320,7 @@ mod tests { normalization_method: EmbeddingNormalizationMethod::L2, }; - let sub_batches = params.chunk_evenly_with_cap(agents(4), chunk_cap(256)); + let sub_batches = params.chunk_evenly_with_cap(4, chunk_cap(256)); let is_l2 = |normalization_method: &EmbeddingNormalizationMethod| { matches!(normalization_method, EmbeddingNormalizationMethod::L2) @@ -331,7 +339,7 @@ mod tests { 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(agents(5), chunk_cap(256)); + let sub_batches = params.chunk_evenly_with_cap(5, chunk_cap(256)); let collected_ids: Vec = sub_batches .iter() diff --git a/paddler_openai_translation/src/openai_translation_error.rs b/paddler_openai_translation/src/openai_translation_error.rs index ce1fea972..32e78d5f7 100644 --- a/paddler_openai_translation/src/openai_translation_error.rs +++ b/paddler_openai_translation/src/openai_translation_error.rs @@ -1,6 +1,5 @@ use std::time::SystemTimeError; -use paddler_messaging::inference_mode::InferenceMode; use paddler_messaging::request_params_validation_error::RequestParamsValidationError; #[derive(Debug, thiserror::Error)] @@ -8,9 +7,6 @@ pub enum OpenAITranslationError { #[error("the system clock reads a time before the Unix epoch")] ClockBeforeUnixEpoch(#[source] SystemTimeError), - #[error("the cluster serves {inference_mode:?}, not text generation")] - InferenceModeMismatch { inference_mode: InferenceMode }, - #[error(transparent)] ToolRejected(#[from] RequestParamsValidationError), } diff --git a/paddler_openai_translation/src/responses_request.rs b/paddler_openai_translation/src/responses_request.rs index aef7742ec..a01cd393a 100644 --- a/paddler_openai_translation/src/responses_request.rs +++ b/paddler_openai_translation/src/responses_request.rs @@ -5,8 +5,6 @@ use nanoid::nanoid; use serde::Deserialize; use paddler_inference_parameters::sampling_parameters::SamplingParameters; -use paddler_messaging::agent_inference_settings::AgentInferenceSettings; -use paddler_messaging::agent_text_generation_settings::AgentTextGenerationSettings; use paddler_messaging::conversation_history::ConversationHistory; use paddler_messaging::conversation_message::ConversationMessage; use paddler_messaging::conversation_message_content::ConversationMessageContent; @@ -49,7 +47,9 @@ impl ResponsesRequest { pub fn translate( self, now: SystemTime, - agent_inference_settings: &AgentInferenceSettings, + SamplingParameters { + temperature, top_p, .. + }: &SamplingParameters, ) -> Result { let Self { model, @@ -94,18 +94,6 @@ impl ResponsesRequest { .collect::, _>>()?; let created_at = timestamp_from(now)?; - let AgentInferenceSettings::TextGeneration(AgentTextGenerationSettings { - sampling_parameters: - SamplingParameters { - temperature, top_p, .. - }, - .. - }) = agent_inference_settings - else { - return Err(OpenAITranslationError::InferenceModeMismatch { - inference_mode: agent_inference_settings.inference_mode(), - }); - }; let header = ResponsesResponseHeader { created_at, id: format!("resp_{}", nanoid!()), @@ -147,8 +135,7 @@ mod tests { use serde_json::from_value; use serde_json::json; - use paddler_messaging::agent_inference_settings::AgentInferenceSettings; - use paddler_messaging::agent_text_generation_settings::AgentTextGenerationSettings; + use paddler_inference_parameters::sampling_parameters::SamplingParameters; use paddler_messaging::conversation_message_content::ConversationMessageContent; use paddler_messaging::conversation_message_content_part::ConversationMessageContentPart; use paddler_messaging::grammar_constraint::GrammarConstraint; @@ -160,15 +147,11 @@ mod tests { use crate::openai_translation_error::OpenAITranslationError; use crate::translated_responses_request::TranslatedResponsesRequest; - fn text_generation_settings() -> AgentInferenceSettings { - AgentInferenceSettings::TextGeneration(AgentTextGenerationSettings::default()) - } - fn translated_from(value: Value) -> TranslatedResponsesRequest { let params: ResponsesRequest = from_value(value).unwrap(); params - .translate(UNIX_EPOCH, &text_generation_settings()) + .translate(UNIX_EPOCH, &SamplingParameters::default()) .unwrap() } @@ -453,7 +436,7 @@ mod tests { .unwrap(); assert!(matches!( - params.translate(UNIX_EPOCH, &text_generation_settings()), + params.translate(UNIX_EPOCH, &SamplingParameters::default()), Err(OpenAITranslationError::ToolRejected( RequestParamsValidationError::RequiredFieldNotInProperties { field } )) if field == "absent" diff --git a/paddler_openai_translation/tests/a_request_translated_before_the_unix_epoch_is_refused.rs b/paddler_openai_translation/tests/a_request_translated_before_the_unix_epoch_is_refused.rs index 25a749909..ec8721b9e 100644 --- a/paddler_openai_translation/tests/a_request_translated_before_the_unix_epoch_is_refused.rs +++ b/paddler_openai_translation/tests/a_request_translated_before_the_unix_epoch_is_refused.rs @@ -4,8 +4,7 @@ use std::time::UNIX_EPOCH; use serde_json::from_value; use serde_json::json; -use paddler_messaging::agent_inference_settings::AgentInferenceSettings; -use paddler_messaging::agent_text_generation_settings::AgentTextGenerationSettings; +use paddler_inference_parameters::sampling_parameters::SamplingParameters; use paddler_openai_translation::chat_completion_request::ChatCompletionRequest; use paddler_openai_translation::openai_translation_error::OpenAITranslationError; use paddler_openai_translation::responses_request::ResponsesRequest; @@ -28,10 +27,7 @@ fn a_request_translated_before_the_unix_epoch_is_refused() { if clock_error.duration() == Duration::from_secs(1) )); assert!(matches!( - responses_request.translate( - before_unix_epoch, - &AgentInferenceSettings::TextGeneration(AgentTextGenerationSettings::default()) - ), + responses_request.translate(before_unix_epoch, &SamplingParameters::default()), Err(OpenAITranslationError::ClockBeforeUnixEpoch(clock_error)) if clock_error.duration() == Duration::from_secs(1) )); diff --git a/paddler_openai_translation/tests/a_responses_request_to_a_cluster_of_another_inference_mode_is_refused.rs b/paddler_openai_translation/tests/a_responses_request_to_a_cluster_of_another_inference_mode_is_refused.rs deleted file mode 100644 index 17009c5c4..000000000 --- a/paddler_openai_translation/tests/a_responses_request_to_a_cluster_of_another_inference_mode_is_refused.rs +++ /dev/null @@ -1,26 +0,0 @@ -use std::time::UNIX_EPOCH; - -use serde_json::from_value; -use serde_json::json; - -use paddler_inference_parameters::embedding_parameters::EmbeddingParameters; -use paddler_messaging::agent_inference_settings::AgentInferenceSettings; -use paddler_messaging::inference_mode::InferenceMode; -use paddler_openai_translation::openai_translation_error::OpenAITranslationError; -use paddler_openai_translation::responses_request::ResponsesRequest; - -#[test] -fn a_responses_request_to_a_cluster_of_another_inference_mode_is_refused() { - let responses_request: ResponsesRequest = - from_value(json!({"model": "test-model", "input": "hi"})) - .expect("the responses request must deserialize"); - - assert!(matches!( - responses_request.translate( - UNIX_EPOCH, - &AgentInferenceSettings::Embeddings(EmbeddingParameters::default()) - ), - Err(OpenAITranslationError::InferenceModeMismatch { inference_mode }) - if inference_mode == InferenceMode::Embeddings - )); -} diff --git a/paddler_openai_translation/tests/main.rs b/paddler_openai_translation/tests/main.rs index d2ffa8616..3e811cecf 100644 --- a/paddler_openai_translation/tests/main.rs +++ b/paddler_openai_translation/tests/main.rs @@ -1,5 +1,4 @@ mod a_chat_completion_request_with_an_unsatisfiable_tool_is_refused; mod a_request_translated_before_the_unix_epoch_is_refused; -mod a_responses_request_to_a_cluster_of_another_inference_mode_is_refused; mod chat_completion_requests_translate_into_their_delivery; mod responses_requests_report_the_sampling_parameters_in_effect; diff --git a/paddler_openai_translation/tests/responses_requests_report_the_sampling_parameters_in_effect.rs b/paddler_openai_translation/tests/responses_requests_report_the_sampling_parameters_in_effect.rs index ad538190c..808949a5b 100644 --- a/paddler_openai_translation/tests/responses_requests_report_the_sampling_parameters_in_effect.rs +++ b/paddler_openai_translation/tests/responses_requests_report_the_sampling_parameters_in_effect.rs @@ -7,8 +7,6 @@ use serde_json::json; use serde_json::to_value; use paddler_inference_parameters::sampling_parameters::SamplingParameters; -use paddler_messaging::agent_inference_settings::AgentInferenceSettings; -use paddler_messaging::agent_text_generation_settings::AgentTextGenerationSettings; use paddler_messaging::generation_finish::GenerationFinish; use paddler_messaging::generation_summary::GenerationSummary; use paddler_openai_translation::completes_generation::CompletesGeneration as _; @@ -20,19 +18,15 @@ use paddler_openai_translation::responses_request::ResponsesRequest; #[cfg(test)] fn first_response_snapshot_of(request: Value) -> Value { - let agent_inference_settings = - AgentInferenceSettings::TextGeneration(AgentTextGenerationSettings { - sampling_parameters: SamplingParameters { - temperature: 0.25, - top_p: 0.5, - ..SamplingParameters::default() - }, - ..AgentTextGenerationSettings::default() - }); + let sampling_parameters = SamplingParameters { + temperature: 0.25, + top_p: 0.5, + ..SamplingParameters::default() + }; match from_value::(request) .expect("the request must deserialize") - .translate(UNIX_EPOCH, &agent_inference_settings) + .translate(UNIX_EPOCH, &sampling_parameters) .expect("the request must translate") .delivery { diff --git a/paddler_state_database/src/ensure_requested_state_serves_cluster_mode.rs b/paddler_state_database/src/ensure_requested_state_serves_cluster_mode.rs deleted file mode 100644 index 125275b51..000000000 --- a/paddler_state_database/src/ensure_requested_state_serves_cluster_mode.rs +++ /dev/null @@ -1,54 +0,0 @@ -use paddler_messaging::balancer_desired_state::BalancerDesiredState; -use paddler_messaging::inference_mode::InferenceMode; - -use crate::state_database_error::StateDatabaseError; - -pub fn ensure_requested_state_serves_cluster_mode( - cluster_inference_mode: InferenceMode, - requested_desired_state: &BalancerDesiredState, -) -> Result<(), StateDatabaseError> { - let requested_inference_mode = requested_desired_state.inference_settings.inference_mode(); - - if requested_inference_mode == cluster_inference_mode { - Ok(()) - } else { - Err(StateDatabaseError::RequestedStateServesAnotherMode { - cluster_inference_mode, - requested_inference_mode, - }) - } -} - -#[cfg(test)] -mod tests { - use paddler_messaging::balancer_desired_state::BalancerDesiredState; - use paddler_messaging::inference_mode::InferenceMode; - - use super::ensure_requested_state_serves_cluster_mode; - use crate::state_database_error::StateDatabaseError; - - #[test] - fn a_requested_state_of_the_cluster_mode_is_accepted() { - assert!( - ensure_requested_state_serves_cluster_mode( - InferenceMode::TextGeneration, - &BalancerDesiredState::unconfigured(InferenceMode::TextGeneration) - ) - .is_ok() - ); - } - - #[test] - fn a_requested_state_of_another_mode_is_refused() { - assert!(matches!( - ensure_requested_state_serves_cluster_mode( - InferenceMode::Embeddings, - &BalancerDesiredState::unconfigured(InferenceMode::TextGeneration) - ), - Err(StateDatabaseError::RequestedStateServesAnotherMode { - cluster_inference_mode: InferenceMode::Embeddings, - requested_inference_mode: InferenceMode::TextGeneration, - }) - )); - } -} diff --git a/paddler_state_database/src/ensure_stored_state_serves_cluster_mode.rs b/paddler_state_database/src/ensure_stored_state_serves_cluster_mode.rs deleted file mode 100644 index ca02131bd..000000000 --- a/paddler_state_database/src/ensure_stored_state_serves_cluster_mode.rs +++ /dev/null @@ -1,57 +0,0 @@ -use paddler_messaging::balancer_desired_state::BalancerDesiredState; -use paddler_messaging::inference_mode::InferenceMode; - -use crate::state_database_error::StateDatabaseError; - -pub fn ensure_stored_state_serves_cluster_mode( - cluster_inference_mode: InferenceMode, - stored_desired_state: BalancerDesiredState, -) -> Result { - let stored_inference_mode = stored_desired_state.inference_settings.inference_mode(); - - if stored_inference_mode == cluster_inference_mode { - Ok(stored_desired_state) - } else { - Err(StateDatabaseError::StoredStateServesAnotherMode { - cluster_inference_mode, - stored_inference_mode, - }) - } -} - -#[cfg(test)] -mod tests { - use paddler_messaging::balancer_desired_state::BalancerDesiredState; - use paddler_messaging::inference_mode::InferenceMode; - - use super::ensure_stored_state_serves_cluster_mode; - use crate::state_database_error::StateDatabaseError; - - #[test] - fn a_stored_state_of_the_cluster_mode_is_returned() { - let stored_desired_state = BalancerDesiredState::unconfigured(InferenceMode::Embeddings); - - assert_eq!( - ensure_stored_state_serves_cluster_mode( - InferenceMode::Embeddings, - stored_desired_state.clone() - ) - .unwrap(), - stored_desired_state - ); - } - - #[test] - fn a_stored_state_of_another_mode_is_refused() { - assert!(matches!( - ensure_stored_state_serves_cluster_mode( - InferenceMode::TextGeneration, - BalancerDesiredState::unconfigured(InferenceMode::Embeddings) - ), - Err(StateDatabaseError::StoredStateServesAnotherMode { - cluster_inference_mode: InferenceMode::TextGeneration, - stored_inference_mode: InferenceMode::Embeddings, - }) - )); - } -} diff --git a/paddler_state_database/src/file.rs b/paddler_state_database/src/file.rs index 69e40df0e..2a6a7f9dc 100644 --- a/paddler_state_database/src/file.rs +++ b/paddler_state_database/src/file.rs @@ -13,10 +13,7 @@ use tokio::sync::RwLock; use tokio::sync::watch; use paddler_messaging::balancer_desired_state::BalancerDesiredState; -use paddler_messaging::inference_mode::InferenceMode; -use crate::ensure_requested_state_serves_cluster_mode::ensure_requested_state_serves_cluster_mode; -use crate::ensure_stored_state_serves_cluster_mode::ensure_stored_state_serves_cluster_mode; use crate::schema::Schema; use crate::schema_version_header::SchemaVersionHeader; use crate::state_database::StateDatabase; @@ -25,7 +22,6 @@ use crate::state_database_schema_version::StateDatabaseSchemaVersion; pub struct File { balancer_desired_state_notify_tx: watch::Sender, - cluster_inference_mode: InferenceMode, path: PathBuf, write_lock: RwLock<()>, } @@ -34,12 +30,10 @@ impl File { #[must_use] pub fn new( balancer_desired_state_notify_tx: watch::Sender, - cluster_inference_mode: InferenceMode, path: PathBuf, ) -> Self { Self { balancer_desired_state_notify_tx, - cluster_inference_mode, path, write_lock: RwLock::new(()), } @@ -70,11 +64,11 @@ impl File { Ok(content) => self.parse_schema(&content), Err(read_error) if read_error.kind() == ErrorKind::NotFound => { warn!( - "State database file not found; trying to store an unconfigured state: '{}'", + "State database file not found; trying to store the default state: '{}'", self.path.display() ); - self.store_unconfigured_schema().await + self.store_default_schema().await } Err(source) => Err(StateDatabaseError::FileReadFailed { path: self.path.clone(), @@ -83,8 +77,8 @@ impl File { } } - async fn store_unconfigured_schema(&self) -> Result { - let schema = Schema::unconfigured(self.cluster_inference_mode); + async fn store_default_schema(&self) -> Result { + let schema = Schema::default(); self.store_schema(&schema).await?; @@ -131,23 +125,15 @@ impl StateDatabase for File { async fn read_balancer_desired_state( &self, ) -> Result { - let schema = self.read_schema_from_file().await?; - - ensure_stored_state_serves_cluster_mode( - self.cluster_inference_mode, - schema.balancer_desired_state, - ) + self.read_schema_from_file() + .await + .map(|schema| schema.balancer_desired_state) } async fn store_balancer_desired_state( &self, balancer_desired_state: &BalancerDesiredState, ) -> Result<(), StateDatabaseError> { - ensure_requested_state_serves_cluster_mode( - self.cluster_inference_mode, - balancer_desired_state, - )?; - let mut schema = self.read_schema_from_file().await?; schema.balancer_desired_state = balancer_desired_state.clone(); @@ -170,9 +156,9 @@ mod tests { use paddler_messaging::agent_desired_model::AgentDesiredModel; use paddler_messaging::balancer_desired_state::BalancerDesiredState; - use paddler_messaging::balancer_inference_settings::BalancerInferenceSettings; use paddler_messaging::balancer_text_generation_settings::BalancerTextGenerationSettings; use paddler_messaging::chat_template::ChatTemplate; + use paddler_messaging::decision_settings::DecisionSettings; use paddler_messaging::inference_mode::InferenceMode; use super::File; @@ -180,22 +166,17 @@ mod tests { use crate::state_database::StateDatabase; use crate::state_database_error::StateDatabaseError; - fn text_generation_file(path: PathBuf) -> File { - let (balancer_desired_state_notify_tx, _balancer_desired_state_notify_rx) = watch::channel( - BalancerDesiredState::unconfigured(InferenceMode::TextGeneration), - ); + fn state_file(path: PathBuf) -> File { + let (balancer_desired_state_notify_tx, _balancer_desired_state_notify_rx) = + watch::channel(BalancerDesiredState::default()); - File::new( - balancer_desired_state_notify_tx, - InferenceMode::TextGeneration, - path, - ) + File::new(balancer_desired_state_notify_tx, path) } - fn text_generation_state_with_model(model_path: &str) -> BalancerDesiredState { + fn state_with_model(model_path: &str) -> BalancerDesiredState { BalancerDesiredState { model: AgentDesiredModel::LocalToAgent(model_path.to_owned()), - ..BalancerDesiredState::unconfigured(InferenceMode::TextGeneration) + ..BalancerDesiredState::default() } } @@ -203,9 +184,9 @@ mod tests { async fn reads_back_the_stored_desired_state_from_the_file() { let temp_dir = TempDir::new().unwrap(); let path = temp_dir.path().join("state.json"); - let database = text_generation_file(path.clone()); + let database = state_file(path.clone()); - let desired_state = text_generation_state_with_model("stored_model_path"); + let desired_state = state_with_model("stored_model_path"); database .store_balancer_desired_state(&desired_state) @@ -219,17 +200,14 @@ mod tests { } #[tokio::test] - async fn reading_missing_file_stores_and_returns_an_unconfigured_state() { + async fn reading_missing_file_stores_and_returns_the_default_state() { let temp_dir = TempDir::new().unwrap(); let path = temp_dir.path().join("not_yet_created.json"); - let database = text_generation_file(path.clone()); + let database = state_file(path.clone()); let read_state = database.read_balancer_desired_state().await.unwrap(); - assert_eq!( - read_state, - BalancerDesiredState::unconfigured(InferenceMode::TextGeneration) - ); + assert_eq!(read_state, BalancerDesiredState::default()); assert!(metadata(&path).await.unwrap().is_file()); } @@ -238,7 +216,7 @@ mod tests { let temp_file = NamedTempFile::new().unwrap(); let path = temp_file.path().to_path_buf(); write(&path, b"this is not valid json").await.unwrap(); - let database = text_generation_file(path.clone()); + let database = state_file(path.clone()); let read_result = database.read_balancer_desired_state().await; @@ -252,7 +230,7 @@ mod tests { async fn reading_an_empty_file_fails_and_leaves_the_file_untouched() { let temp_file = NamedTempFile::new().unwrap(); let path = temp_file.path().to_path_buf(); - let database = text_generation_file(path.clone()); + let database = state_file(path.clone()); let read_result = database.read_balancer_desired_state().await; @@ -266,7 +244,7 @@ mod tests { #[tokio::test] async fn reading_a_directory_path_returns_non_not_found_error() { let temp_dir = TempDir::new().unwrap(); - let database = text_generation_file(temp_dir.path().to_path_buf()); + let database = state_file(temp_dir.path().to_path_buf()); let read_result = database.read_balancer_desired_state().await; @@ -278,10 +256,10 @@ mod tests { } #[tokio::test] - async fn storing_an_unconfigured_state_fails_when_parent_directory_is_missing() { + async fn storing_the_default_state_fails_when_parent_directory_is_missing() { let temp_dir = TempDir::new().unwrap(); let path: PathBuf = temp_dir.path().join("missing_directory").join("state.json"); - let database = text_generation_file(path.clone()); + let database = state_file(path.clone()); let read_result = database.read_balancer_desired_state().await; @@ -295,12 +273,10 @@ mod tests { #[tokio::test] async fn updating_schema_fails_when_path_is_a_directory() { let temp_dir = TempDir::new().unwrap(); - let database = text_generation_file(temp_dir.path().to_path_buf()); + let database = state_file(temp_dir.path().to_path_buf()); let store_result = database - .store_balancer_desired_state(&BalancerDesiredState::unconfigured( - InferenceMode::TextGeneration, - )) + .store_balancer_desired_state(&BalancerDesiredState::default()) .await; assert!(matches!( @@ -313,11 +289,9 @@ mod tests { #[cfg(target_os = "linux")] #[tokio::test] async fn storing_a_small_schema_on_a_character_device_fails_to_sync() { - let database = text_generation_file(PathBuf::from("/dev/full")); + let database = state_file(PathBuf::from("/dev/full")); - let store_result = database - .store_schema(&Schema::unconfigured(InferenceMode::TextGeneration)) - .await; + let store_result = database.store_schema(&Schema::default()).await; assert!(matches!( store_result, @@ -331,9 +305,9 @@ mod tests { async fn storing_a_large_schema_surfaces_the_write_error_during_write_all() { const TOKIO_FILE_BUFFER_BYTES: usize = 2 * 1024 * 1024; - let database = text_generation_file(PathBuf::from("/dev/full")); + let database = state_file(PathBuf::from("/dev/full")); - let mut schema = Schema::unconfigured(InferenceMode::TextGeneration); + let mut schema = Schema::default(); schema.balancer_desired_state.model = AgentDesiredModel::LocalToAgent("x".repeat(TOKIO_FILE_BUFFER_BYTES * 2)); @@ -349,11 +323,9 @@ mod tests { #[cfg(target_os = "macos")] #[tokio::test] async fn storing_to_dev_full_surfaces_permission_denied() { - let database = text_generation_file(PathBuf::from("/dev/full")); + let database = state_file(PathBuf::from("/dev/full")); - let store_result = database - .store_schema(&Schema::unconfigured(InferenceMode::TextGeneration)) - .await; + let store_result = database.store_schema(&Schema::default()).await; assert!(matches!( store_result, @@ -363,30 +335,32 @@ mod tests { } #[tokio::test] - async fn persists_the_chat_template_override_across_instances() { + async fn persists_the_settings_of_inactive_inference_modes_across_instances() { let temp_dir = TempDir::new().unwrap(); let path = temp_dir.path().join("state.json"); - let chat_template_override = Some(ChatTemplate { - content: "{% for message in messages %}{{ message.content }}{% endfor %}".to_owned(), - }); let desired_state = BalancerDesiredState { - inference_settings: BalancerInferenceSettings::TextGeneration( - BalancerTextGenerationSettings { - chat_template_override, - use_chat_template_override: true, - ..BalancerTextGenerationSettings::default() - }, - ), - ..text_generation_state_with_model("test_model_path") + decision: DecisionSettings { + pointer_head: AgentDesiredModel::LocalToAgent("pointer_head.gguf".to_owned()), + }, + inference_mode: InferenceMode::Embeddings, + text_generation: BalancerTextGenerationSettings { + chat_template_override: Some(ChatTemplate { + content: "{% for message in messages %}{{ message.content }}{% endfor %}" + .to_owned(), + }), + use_chat_template_override: true, + ..BalancerTextGenerationSettings::default() + }, + ..state_with_model("test_model_path") }; - text_generation_file(path.clone()) + state_file(path.clone()) .store_balancer_desired_state(&desired_state) .await .unwrap(); assert_eq!( - text_generation_file(path) + state_file(path) .read_balancer_desired_state() .await .unwrap(), @@ -402,9 +376,7 @@ mod tests { .await .unwrap(); - let read_result = text_generation_file(path.clone()) - .read_balancer_desired_state() - .await; + let read_result = state_file(path.clone()).read_balancer_desired_state().await; assert!(matches!( read_result, @@ -412,26 +384,4 @@ mod tests { if found == r#""1""# && unsupported_path == path )); } - - #[tokio::test] - async fn storing_a_state_of_another_mode_is_refused_and_leaves_the_file_untouched() { - let temp_dir = TempDir::new().unwrap(); - let path = temp_dir.path().join("state.json"); - let database = text_generation_file(path.clone()); - - let store_result = database - .store_balancer_desired_state(&BalancerDesiredState::unconfigured( - InferenceMode::Embeddings, - )) - .await; - - assert!(matches!( - store_result, - Err(StateDatabaseError::RequestedStateServesAnotherMode { - cluster_inference_mode: InferenceMode::TextGeneration, - requested_inference_mode: InferenceMode::Embeddings, - }) - )); - assert!(metadata(&path).await.is_err()); - } } diff --git a/paddler_state_database/src/lib.rs b/paddler_state_database/src/lib.rs index 70482d013..f105686eb 100644 --- a/paddler_state_database/src/lib.rs +++ b/paddler_state_database/src/lib.rs @@ -1,5 +1,3 @@ -mod ensure_requested_state_serves_cluster_mode; -mod ensure_stored_state_serves_cluster_mode; pub mod file; pub mod memory; mod schema; diff --git a/paddler_state_database/src/memory.rs b/paddler_state_database/src/memory.rs index bf04808b6..7b8477f32 100644 --- a/paddler_state_database/src/memory.rs +++ b/paddler_state_database/src/memory.rs @@ -3,30 +3,24 @@ use parking_lot::RwLock; use tokio::sync::watch; use paddler_messaging::balancer_desired_state::BalancerDesiredState; -use paddler_messaging::inference_mode::InferenceMode; -use crate::ensure_requested_state_serves_cluster_mode::ensure_requested_state_serves_cluster_mode; -use crate::ensure_stored_state_serves_cluster_mode::ensure_stored_state_serves_cluster_mode; use crate::state_database::StateDatabase; use crate::state_database_error::StateDatabaseError; pub struct Memory { balancer_desired_state: RwLock, balancer_desired_state_notify_tx: watch::Sender, - cluster_inference_mode: InferenceMode, } impl Memory { #[must_use] pub const fn new( balancer_desired_state_notify_tx: watch::Sender, - cluster_inference_mode: InferenceMode, initial_desired_state: BalancerDesiredState, ) -> Self { Self { balancer_desired_state: RwLock::new(initial_desired_state), balancer_desired_state_notify_tx, - cluster_inference_mode, } } } @@ -36,18 +30,13 @@ impl StateDatabase for Memory { async fn read_balancer_desired_state( &self, ) -> Result { - ensure_stored_state_serves_cluster_mode( - self.cluster_inference_mode, - self.balancer_desired_state.read().clone(), - ) + Ok(self.balancer_desired_state.read().clone()) } async fn store_balancer_desired_state( &self, state: &BalancerDesiredState, ) -> Result<(), StateDatabaseError> { - ensure_requested_state_serves_cluster_mode(self.cluster_inference_mode, state)?; - { let mut balancer_desired_state = self.balancer_desired_state.write(); @@ -71,26 +60,16 @@ mod tests { use super::Memory; use crate::state_database::StateDatabase; - use crate::state_database_error::StateDatabaseError; - - fn embeddings_memory() -> Memory { - let unconfigured_state = BalancerDesiredState::unconfigured(InferenceMode::Embeddings); - let (balancer_desired_state_tx, _balancer_desired_state_rx) = - watch::channel(unconfigured_state.clone()); - - Memory::new( - balancer_desired_state_tx, - InferenceMode::Embeddings, - unconfigured_state, - ) - } #[tokio::test] async fn reads_back_the_stored_desired_state() { - let database = embeddings_memory(); + let (balancer_desired_state_tx, _balancer_desired_state_rx) = + watch::channel(BalancerDesiredState::default()); + let database = Memory::new(balancer_desired_state_tx, BalancerDesiredState::default()); let desired_state = BalancerDesiredState { + inference_mode: InferenceMode::Embeddings, model: AgentDesiredModel::LocalToAgent("test_model_path".to_owned()), - ..BalancerDesiredState::unconfigured(InferenceMode::Embeddings) + ..BalancerDesiredState::default() }; database @@ -103,24 +82,4 @@ mod tests { desired_state ); } - - #[tokio::test] - async fn storing_a_state_of_another_mode_is_refused_and_keeps_the_stored_state() { - let database = embeddings_memory(); - - let store_result = database - .store_balancer_desired_state(&BalancerDesiredState::unconfigured( - InferenceMode::TextGeneration, - )) - .await; - - assert!(matches!( - store_result, - Err(StateDatabaseError::RequestedStateServesAnotherMode { .. }) - )); - assert_eq!( - database.read_balancer_desired_state().await.unwrap(), - BalancerDesiredState::unconfigured(InferenceMode::Embeddings) - ); - } } diff --git a/paddler_state_database/src/schema.rs b/paddler_state_database/src/schema.rs index e635bace0..e082a2d2a 100644 --- a/paddler_state_database/src/schema.rs +++ b/paddler_state_database/src/schema.rs @@ -2,23 +2,12 @@ use serde::Deserialize; use serde::Serialize; use paddler_messaging::balancer_desired_state::BalancerDesiredState; -use paddler_messaging::inference_mode::InferenceMode; use crate::state_database_schema_version::StateDatabaseSchemaVersion; -#[derive(Deserialize, Serialize)] +#[derive(Default, Deserialize, Serialize)] #[serde(deny_unknown_fields)] pub struct Schema { pub balancer_desired_state: BalancerDesiredState, pub version: StateDatabaseSchemaVersion, } - -impl Schema { - #[must_use] - pub fn unconfigured(inference_mode: InferenceMode) -> Self { - Self { - balancer_desired_state: BalancerDesiredState::unconfigured(inference_mode), - version: StateDatabaseSchemaVersion::V2, - } - } -} diff --git a/paddler_state_database/src/state_database_error.rs b/paddler_state_database/src/state_database_error.rs index 6d9a4d11b..5c94436b9 100644 --- a/paddler_state_database/src/state_database_error.rs +++ b/paddler_state_database/src/state_database_error.rs @@ -5,8 +5,6 @@ use serde_json::Error as SerdeJsonError; use thiserror::Error; use url::ParseError; -use paddler_messaging::inference_mode::InferenceMode; - #[derive(Debug, Error)] pub enum StateDatabaseError { #[error("Unable to parse the state database file '{path_display}'. Either that is not a valid database file, or this version of Paddler is incompatible with it.", path_display = path.display())] @@ -53,26 +51,12 @@ pub enum StateDatabaseError { #[source] source: io::Error, }, - #[error( - "The desired state serves {requested_inference_mode:?}, but this cluster serves {cluster_inference_mode:?}" - )] - RequestedStateServesAnotherMode { - cluster_inference_mode: InferenceMode, - requested_inference_mode: InferenceMode, - }, #[error("Failed to serialize the state database schema")] SchemaUnserializable(#[source] SerdeJsonError), #[error("The state database file '{path_display}' uses schema version {found}, but this version of Paddler only reads version 2. Start from a new state database file.", path_display = path.display())] SchemaVersionUnsupported { found: String, path: PathBuf }, #[error("Unsupported state database scheme '{scheme}'")] SchemeUnsupported { scheme: String }, - #[error( - "The stored desired state serves {stored_inference_mode:?}, but this cluster serves {cluster_inference_mode:?}. Start the cluster in that mode, or use a different state database." - )] - StoredStateServesAnotherMode { - cluster_inference_mode: InferenceMode, - stored_inference_mode: InferenceMode, - }, #[error("Invalid state database URL '{input}'")] UrlInvalid { input: String, diff --git a/paddler_state_database/src/state_database_schema_version.rs b/paddler_state_database/src/state_database_schema_version.rs index 6f42c93ec..51c97f0c1 100644 --- a/paddler_state_database/src/state_database_schema_version.rs +++ b/paddler_state_database/src/state_database_schema_version.rs @@ -1,8 +1,9 @@ use serde::Deserialize; use serde::Serialize; -#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[derive(Clone, Copy, Debug, Default, Deserialize, Eq, PartialEq, Serialize)] pub enum StateDatabaseSchemaVersion { + #[default] #[serde(rename = "2")] V2, } diff --git a/paddler_state_database/src/state_database_type.rs b/paddler_state_database/src/state_database_type.rs index 258ddeba8..5653e3a93 100644 --- a/paddler_state_database/src/state_database_type.rs +++ b/paddler_state_database/src/state_database_type.rs @@ -23,8 +23,7 @@ fn relative_path_error(path: &str) -> StateDatabaseError { #[derive(Clone)] pub enum StateDatabaseType { File(PathBuf), - Memory, - MemoryStartingWith(Box), + Memory(Box), } impl FromStr for StateDatabaseType { @@ -55,7 +54,7 @@ impl FromStr for StateDatabaseType { Ok(Self::File(PathBuf::from(path))) } - "memory" => Ok(Self::Memory), + "memory" => Ok(Self::Memory(Box::default())), scheme => Err(StateDatabaseError::SchemeUnsupported { scheme: scheme.to_owned(), }), @@ -72,17 +71,20 @@ mod tests { use tempfile::TempDir; + use paddler_messaging::balancer_desired_state::BalancerDesiredState; + use super::StateDatabaseType; use crate::state_database_error::StateDatabaseError; #[test] - fn parses_a_memory_url() { + fn parses_a_memory_url_into_the_default_state() { let result = StateDatabaseType::from_str("memory://").unwrap(); - assert_eq!( - discriminant(&result), - discriminant(&StateDatabaseType::Memory), - ); + assert!(matches!( + result, + StateDatabaseType::Memory(initial_desired_state) + if *initial_desired_state == BalancerDesiredState::default() + )); } #[test] diff --git a/paddler_test_cluster_harness/src/cluster.rs b/paddler_test_cluster_harness/src/cluster.rs index 3c0d08d20..b7bb318bb 100644 --- a/paddler_test_cluster_harness/src/cluster.rs +++ b/paddler_test_cluster_harness/src/cluster.rs @@ -15,8 +15,11 @@ use paddler_client::client_management::ClientManagement; use paddler_client::inference_message_stream::InferenceMessageStream; use paddler_client::reports_health::ReportsHealth as _; use paddler_messaging::agent_controller_pool_snapshot::AgentControllerPoolSnapshot; +use paddler_messaging::agent_controller_snapshot::AgentControllerSnapshot; use paddler_messaging::agent_issue::AgentIssue; +use paddler_messaging::agent_runtime_status::AgentRuntimeStatus; use paddler_messaging::buffered_request_manager_snapshot::BufferedRequestManagerSnapshot; +use paddler_messaging::inference_mode::InferenceMode; use paddler_messaging::request_params::continue_from_conversation_history_params::ContinueFromConversationHistoryParams; use paddler_messaging::request_params::continue_from_conversation_history_params::tool::tool_params::function_call::parameters_schema::validated_parameters_schema::ValidatedParametersSchema; use paddler_messaging::request_params::continue_from_raw_prompt_params::ContinueFromRawPromptParams; @@ -392,6 +395,33 @@ impl Cluster { ) -> Result where TIssueMatcher: Fn(&AgentIssue) -> bool, + { + self.wait_for_first_agent(|agent| agent.status.issues.iter().any(&issue_matcher)) + .await + } + + pub async fn wait_for_first_agent_to_serve( + &mut self, + inference_mode: InferenceMode, + ) -> Result { + self.wait_for_first_agent(|agent| { + matches!( + agent.status.runtime, + AgentRuntimeStatus::Serving { + inference_mode: served_inference_mode, + .. + } if served_inference_mode == inference_mode + ) + }) + .await + } + + async fn wait_for_first_agent( + &mut self, + agent_matcher: TAgentMatcher, + ) -> Result + where + TAgentMatcher: Fn(&AgentControllerSnapshot) -> bool, { let agent_id = self .agent_ids @@ -402,9 +432,10 @@ impl Cluster { Ok(self .agents_watcher .until_agent(&agent_id, |snapshot| { - snapshot.agents.iter().any(|agent| { - agent.id == agent_id && agent.status.issues.iter().any(&issue_matcher) - }) + snapshot + .agents + .iter() + .any(|agent| agent.id == agent_id && agent_matcher(agent)) }) .await?) } diff --git a/paddler_test_cluster_harness/src/cluster_desired_state.rs b/paddler_test_cluster_harness/src/cluster_desired_state.rs index e72c6eea9..934d3afa2 100644 --- a/paddler_test_cluster_harness/src/cluster_desired_state.rs +++ b/paddler_test_cluster_harness/src/cluster_desired_state.rs @@ -1,19 +1,6 @@ use paddler_messaging::balancer_desired_state::BalancerDesiredState; -use paddler_messaging::inference_mode::InferenceMode; pub enum ClusterDesiredState { Apply(Box), - KeepStored(InferenceMode), -} - -impl ClusterDesiredState { - #[must_use] - pub const fn inference_mode(&self) -> InferenceMode { - match self { - Self::Apply(balancer_desired_state) => { - balancer_desired_state.inference_settings.inference_mode() - } - Self::KeepStored(inference_mode) => *inference_mode, - } - } + KeepStored, } diff --git a/paddler_test_cluster_harness/src/cluster_params.rs b/paddler_test_cluster_harness/src/cluster_params.rs index 76a92bb17..34205fadd 100644 --- a/paddler_test_cluster_harness/src/cluster_params.rs +++ b/paddler_test_cluster_harness/src/cluster_params.rs @@ -1,8 +1,5 @@ use std::time::Duration; -use paddler_messaging::balancer_desired_state::BalancerDesiredState; -use paddler_messaging::inference_mode::InferenceMode; - use crate::agent_config::AgentConfig; use crate::cluster_desired_state::ClusterDesiredState; use crate::longer_than_any_test_run::LONGER_THAN_ANY_TEST_RUN; @@ -24,9 +21,7 @@ impl Default for ClusterParams { Self { agents: AgentConfig::uniform(1, 4), buffered_request_timeout: LONGER_THAN_ANY_TEST_RUN, - desired_state: ClusterDesiredState::Apply(Box::new( - BalancerDesiredState::unconfigured(InferenceMode::TextGeneration), - )), + desired_state: ClusterDesiredState::Apply(Box::default()), inference_cors_allowed_hosts: Vec::new(), inference_item_timeout: LONGER_THAN_ANY_TEST_RUN, management_cors_allowed_hosts: Vec::new(), diff --git a/paddler_test_cluster_harness/src/collect_embedding_results.rs b/paddler_test_cluster_harness/src/collect_embedding_results.rs index 689ee2b9f..699614c0a 100644 --- a/paddler_test_cluster_harness/src/collect_embedding_results.rs +++ b/paddler_test_cluster_harness/src/collect_embedding_results.rs @@ -73,7 +73,7 @@ pub async fn collect_embedding_results( request_ids.insert(request_id); wire_errors.push(error); } - unexpected_message @ InferenceMessage::Response(_) => { + unexpected_message => { return Err(ClusterHarnessError::EmbeddingStreamMessageUnexpected { message: Box::new(unexpected_message), } @@ -93,3 +93,42 @@ pub async fn collect_embedding_results( wire_errors, }) } + +#[cfg(test)] +mod tests { + use futures_util::stream::iter; + + use paddler_messaging::generated_token_result::GeneratedTokenResult; + use paddler_messaging::inference_client::message::Message as InferenceMessage; + use paddler_messaging::inference_client::response::Response as InferenceResponse; + use paddler_messaging::jsonrpc::response_envelope::ResponseEnvelope; + + use super::collect_embedding_results; + use crate::cluster_harness_error::ClusterHarnessError; + + #[tokio::test] + async fn refuses_a_message_that_is_not_an_embedding_result() { + let collection_error = collect_embedding_results(Box::pin(iter([Ok( + InferenceMessage::Response(ResponseEnvelope { + generated_by: None, + request_id: "generation-request".to_owned(), + response: InferenceResponse::GeneratedToken(GeneratedTokenResult::ContentToken( + "piece".to_owned(), + )), + }), + )]))) + .await + .err() + .expect("a stream of another kind of message must be refused"); + + assert!(matches!( + collection_error.downcast_ref::(), + Some(ClusterHarnessError::EmbeddingStreamMessageUnexpected { message }) + if matches!( + message.as_ref(), + InferenceMessage::Response(ResponseEnvelope { request_id, .. }) + if request_id == "generation-request" + ) + )); + } +} diff --git a/paddler_test_cluster_harness/src/collect_generated_tokens.rs b/paddler_test_cluster_harness/src/collect_generated_tokens.rs index 29a43b30f..921b7c671 100644 --- a/paddler_test_cluster_harness/src/collect_generated_tokens.rs +++ b/paddler_test_cluster_harness/src/collect_generated_tokens.rs @@ -46,7 +46,7 @@ pub async fn collect_generated_tokens( } .into()); } - unexpected_message @ InferenceMessage::Response(_) => { + unexpected_message => { return Err(ClusterHarnessError::TokenStreamMessageUnexpected { message: Box::new(unexpected_message), } @@ -60,3 +60,40 @@ pub async fn collect_generated_tokens( token_results, }) } + +#[cfg(test)] +mod tests { + use futures_util::stream::iter; + + use paddler_messaging::embedding_result::EmbeddingResult; + use paddler_messaging::inference_client::message::Message as InferenceMessage; + use paddler_messaging::inference_client::response::Response as InferenceResponse; + use paddler_messaging::jsonrpc::response_envelope::ResponseEnvelope; + + use super::collect_generated_tokens; + use crate::cluster_harness_error::ClusterHarnessError; + + #[tokio::test] + async fn refuses_a_message_that_is_not_a_generated_token() { + let collection_error = collect_generated_tokens(Box::pin(iter([Ok( + InferenceMessage::Response(ResponseEnvelope { + generated_by: None, + request_id: "embedding-request".to_owned(), + response: InferenceResponse::Embedding(EmbeddingResult::Done), + }), + )]))) + .await + .err() + .expect("a stream of another kind of message must be refused"); + + assert!(matches!( + collection_error.downcast_ref::(), + Some(ClusterHarnessError::TokenStreamMessageUnexpected { message }) + if matches!( + message.as_ref(), + InferenceMessage::Response(ResponseEnvelope { request_id, .. }) + if request_id == "embedding-request" + ) + )); + } +} diff --git a/paddler_test_cluster_harness/src/model_card/mod.rs b/paddler_test_cluster_harness/src/model_card/mod.rs index 0f7722a31..1133b0206 100644 --- a/paddler_test_cluster_harness/src/model_card/mod.rs +++ b/paddler_test_cluster_harness/src/model_card/mod.rs @@ -22,10 +22,10 @@ use paddler_inference_parameters::model_runtime_parameters::ModelRuntimeParamete use paddler_inference_parameters::sampling_parameters::SamplingParameters; use paddler_messaging::agent_desired_model::AgentDesiredModel; use paddler_messaging::balancer_desired_state::BalancerDesiredState; -use paddler_messaging::balancer_inference_settings::BalancerInferenceSettings; use paddler_messaging::balancer_text_generation_settings::BalancerTextGenerationSettings; use paddler_messaging::decision_settings::DecisionSettings; use paddler_messaging::huggingface_model_reference::HuggingFaceModelReference; +use paddler_messaging::inference_mode::InferenceMode; use paddler_messaging::multimodal_settings::MultimodalSettings; pub struct ModelCard { @@ -40,12 +40,7 @@ impl ModelCard { #[must_use] pub fn into_desired_state(self) -> BalancerDesiredState { - self.into_desired_state_with_inference_settings(BalancerInferenceSettings::TextGeneration( - BalancerTextGenerationSettings { - sampling_parameters: SamplingParameters::deterministic(), - ..BalancerTextGenerationSettings::default() - }, - )) + self.into_desired_state_serving(InferenceMode::TextGeneration) } #[must_use] @@ -53,16 +48,18 @@ impl ModelCard { self, multimodal_projection: Self, ) -> BalancerDesiredState { - self.into_desired_state_with_inference_settings(BalancerInferenceSettings::TextGeneration( - BalancerTextGenerationSettings { + let desired_state = self.into_desired_state_serving(InferenceMode::TextGeneration); + + BalancerDesiredState { + text_generation: BalancerTextGenerationSettings { multimodal: MultimodalSettings { projection: multimodal_projection.into_agent_desired_model(), ..MultimodalSettings::default() }, - sampling_parameters: SamplingParameters::deterministic(), - ..BalancerTextGenerationSettings::default() + ..desired_state.text_generation }, - )) + ..desired_state + } } #[must_use] @@ -70,9 +67,10 @@ impl ModelCard { self, pointer_head: AgentDesiredModel, ) -> BalancerDesiredState { - self.into_desired_state_with_inference_settings(BalancerInferenceSettings::Decision( - DecisionSettings { pointer_head }, - )) + BalancerDesiredState { + decision: DecisionSettings { pointer_head }, + ..self.into_desired_state_serving(InferenceMode::Decision) + } } #[must_use] @@ -80,22 +78,25 @@ impl ModelCard { self, embedding_parameters: EmbeddingParameters, ) -> BalancerDesiredState { - self.into_desired_state_with_inference_settings(BalancerInferenceSettings::Embeddings( - embedding_parameters, - )) + BalancerDesiredState { + embeddings: embedding_parameters, + ..self.into_desired_state_serving(InferenceMode::Embeddings) + } } - fn into_desired_state_with_inference_settings( - self, - inference_settings: BalancerInferenceSettings, - ) -> BalancerDesiredState { + fn into_desired_state_serving(self, inference_mode: InferenceMode) -> BalancerDesiredState { BalancerDesiredState { - inference_settings, + inference_mode, model: self.into_agent_desired_model(), model_runtime_parameters: ModelRuntimeParameters { n_gpu_layers: ALL_GPU_LAYERS, ..ModelRuntimeParameters::default() }, + text_generation: BalancerTextGenerationSettings { + sampling_parameters: SamplingParameters::deterministic(), + ..BalancerTextGenerationSettings::default() + }, + ..BalancerDesiredState::default() } } } diff --git a/paddler_test_cluster_harness/src/raw_inference_socket.rs b/paddler_test_cluster_harness/src/raw_inference_socket.rs index ddb06db12..9eabbf885 100644 --- a/paddler_test_cluster_harness/src/raw_inference_socket.rs +++ b/paddler_test_cluster_harness/src/raw_inference_socket.rs @@ -25,12 +25,13 @@ use paddler_messaging::request_params::continue_from_raw_prompt_params::Continue use crate::cluster_harness_error::ClusterHarnessError; -const fn answered_request_id(message: &InferenceClientMessage) -> &str { +const fn answered_request_id(message: &InferenceClientMessage) -> Option<&str> { match message { InferenceClientMessage::Error(ErrorEnvelope { request_id, .. }) | InferenceClientMessage::Response(ResponseEnvelope { request_id, .. }) => { - request_id.as_str() + Some(request_id.as_str()) } + InferenceClientMessage::Notification(_) => None, } } @@ -49,9 +50,13 @@ impl RawInferenceSocket { } pub async fn next_answer(&mut self) -> Result { - self.next_message() - .await? - .ok_or(ClusterHarnessError::InferenceSocketClosedBeforeAnyAnswer) + while let Some(message) = self.next_message().await? { + if answered_request_id(&message).is_some() { + return Ok(message); + } + } + + Err(ClusterHarnessError::InferenceSocketClosedBeforeAnyAnswer) } pub async fn next_answer_to( @@ -59,7 +64,7 @@ impl RawInferenceSocket { request_id: &str, ) -> Result { while let Some(message) = self.next_message().await? { - if answered_request_id(&message) == request_id { + if answered_request_id(&message) == Some(request_id) { return Ok(message); } } diff --git a/paddler_tests/src/decision_cluster_without_agents_params.rs b/paddler_tests/src/cluster_without_agents_serving.rs similarity index 50% rename from paddler_tests/src/decision_cluster_without_agents_params.rs rename to paddler_tests/src/cluster_without_agents_serving.rs index efe5b72b4..7d744206d 100644 --- a/paddler_tests/src/decision_cluster_without_agents_params.rs +++ b/paddler_tests/src/cluster_without_agents_serving.rs @@ -1,12 +1,16 @@ +use paddler_messaging::balancer_desired_state::BalancerDesiredState; use paddler_messaging::inference_mode::InferenceMode; use paddler_test_cluster_harness::cluster_desired_state::ClusterDesiredState; use paddler_test_cluster_harness::cluster_params::ClusterParams; #[must_use] -pub fn decision_cluster_without_agents_params() -> ClusterParams { +pub fn cluster_without_agents_serving(inference_mode: InferenceMode) -> ClusterParams { ClusterParams { agents: Vec::new(), - desired_state: ClusterDesiredState::KeepStored(InferenceMode::Decision), + desired_state: ClusterDesiredState::Apply(Box::new(BalancerDesiredState { + inference_mode, + ..BalancerDesiredState::default() + })), wait_for_slots_ready: false, ..ClusterParams::default() } diff --git a/paddler_tests/src/desired_state_with_chat_template_override.rs b/paddler_tests/src/desired_state_with_chat_template_override.rs index 24deff294..0eb6f0271 100644 --- a/paddler_tests/src/desired_state_with_chat_template_override.rs +++ b/paddler_tests/src/desired_state_with_chat_template_override.rs @@ -1,29 +1,18 @@ -use anyhow::Result; -use anyhow::bail; - use paddler_messaging::balancer_desired_state::BalancerDesiredState; -use paddler_messaging::balancer_inference_settings::BalancerInferenceSettings; use paddler_messaging::balancer_text_generation_settings::BalancerTextGenerationSettings; use paddler_messaging::chat_template::ChatTemplate; +#[must_use] pub fn desired_state_with_chat_template_override( desired_state: BalancerDesiredState, chat_template: ChatTemplate, -) -> Result { - let BalancerInferenceSettings::TextGeneration(text_generation_settings) = - desired_state.inference_settings - else { - bail!("only a text generation desired state overrides the chat template"); - }; - - Ok(BalancerDesiredState { - inference_settings: BalancerInferenceSettings::TextGeneration( - BalancerTextGenerationSettings { - chat_template_override: Some(chat_template), - use_chat_template_override: true, - ..text_generation_settings - }, - ), +) -> BalancerDesiredState { + BalancerDesiredState { + text_generation: BalancerTextGenerationSettings { + chat_template_override: Some(chat_template), + use_chat_template_override: true, + ..desired_state.text_generation + }, ..desired_state - }) + } } diff --git a/paddler_tests/src/desired_state_with_halved_image_resize.rs b/paddler_tests/src/desired_state_with_halved_image_resize.rs index cbd5bfc7c..c2e5df412 100644 --- a/paddler_tests/src/desired_state_with_halved_image_resize.rs +++ b/paddler_tests/src/desired_state_with_halved_image_resize.rs @@ -1,38 +1,27 @@ use std::num::NonZeroU32; -use anyhow::Result; -use anyhow::bail; - use paddler_messaging::balancer_desired_state::BalancerDesiredState; -use paddler_messaging::balancer_inference_settings::BalancerInferenceSettings; use paddler_messaging::balancer_text_generation_settings::BalancerTextGenerationSettings; use paddler_messaging::multimodal_settings::MultimodalSettings; +const HALF: NonZeroU32 = NonZeroU32::new(2).unwrap(); + +#[must_use] pub fn desired_state_with_halved_image_resize( desired_state: BalancerDesiredState, -) -> Result { - let BalancerInferenceSettings::TextGeneration(text_generation_settings) = - desired_state.inference_settings - else { - bail!("only a text generation desired state resizes images"); - }; - - Ok(BalancerDesiredState { - inference_settings: BalancerInferenceSettings::TextGeneration( - BalancerTextGenerationSettings { - multimodal: MultimodalSettings { - image_resize_to_fit: NonZeroU32::try_from( - text_generation_settings - .multimodal - .image_resize_to_fit - .get() - / 2, - )?, - ..text_generation_settings.multimodal.clone() - }, - ..text_generation_settings +) -> BalancerDesiredState { + BalancerDesiredState { + text_generation: BalancerTextGenerationSettings { + multimodal: MultimodalSettings { + image_resize_to_fit: desired_state + .text_generation + .multimodal + .image_resize_to_fit + .div_ceil(HALF), + ..desired_state.text_generation.multimodal }, - ), + ..desired_state.text_generation + }, ..desired_state - }) + } } diff --git a/paddler_tests/src/desired_state_with_multimodal_projection.rs b/paddler_tests/src/desired_state_with_multimodal_projection.rs index 2b3509c37..7c4b26c67 100644 --- a/paddler_tests/src/desired_state_with_multimodal_projection.rs +++ b/paddler_tests/src/desired_state_with_multimodal_projection.rs @@ -1,32 +1,21 @@ -use anyhow::Result; -use anyhow::bail; - use paddler_messaging::agent_desired_model::AgentDesiredModel; use paddler_messaging::balancer_desired_state::BalancerDesiredState; -use paddler_messaging::balancer_inference_settings::BalancerInferenceSettings; use paddler_messaging::balancer_text_generation_settings::BalancerTextGenerationSettings; use paddler_messaging::multimodal_settings::MultimodalSettings; +#[must_use] pub fn desired_state_with_multimodal_projection( desired_state: BalancerDesiredState, projection: AgentDesiredModel, -) -> Result { - let BalancerInferenceSettings::TextGeneration(text_generation_settings) = - desired_state.inference_settings - else { - bail!("only a text generation desired state loads a multimodal projection"); - }; - - Ok(BalancerDesiredState { - inference_settings: BalancerInferenceSettings::TextGeneration( - BalancerTextGenerationSettings { - multimodal: MultimodalSettings { - projection, - ..text_generation_settings.multimodal.clone() - }, - ..text_generation_settings +) -> BalancerDesiredState { + BalancerDesiredState { + text_generation: BalancerTextGenerationSettings { + multimodal: MultimodalSettings { + projection, + ..desired_state.text_generation.multimodal }, - ), + ..desired_state.text_generation + }, ..desired_state - }) + } } diff --git a/paddler_tests/src/kev_0_8b_desired_state.rs b/paddler_tests/src/kev_0_8b_desired_state.rs index 4faf0cbc7..eef038dea 100644 --- a/paddler_tests/src/kev_0_8b_desired_state.rs +++ b/paddler_tests/src/kev_0_8b_desired_state.rs @@ -2,13 +2,13 @@ use paddler_inference_parameters::all_gpu_layers::ALL_GPU_LAYERS; use paddler_inference_parameters::model_runtime_parameters::ModelRuntimeParameters; use paddler_messaging::agent_desired_model::AgentDesiredModel; use paddler_messaging::balancer_desired_state::BalancerDesiredState; -use paddler_messaging::balancer_inference_settings::BalancerInferenceSettings; use paddler_messaging::decision_settings::DecisionSettings; +use paddler_messaging::inference_mode::InferenceMode; #[must_use] pub fn kev_0_8b_desired_state() -> BalancerDesiredState { BalancerDesiredState { - inference_settings: BalancerInferenceSettings::Decision(DecisionSettings { + decision: DecisionSettings { pointer_head: AgentDesiredModel::LocalToAgent( concat!( env!("CARGO_MANIFEST_DIR"), @@ -16,7 +16,8 @@ pub fn kev_0_8b_desired_state() -> BalancerDesiredState { ) .to_owned(), ), - }), + }, + inference_mode: InferenceMode::Decision, model: AgentDesiredModel::LocalToAgent( concat!(env!("CARGO_MANIFEST_DIR"), "/../target/kev/model.gguf").to_owned(), ), @@ -24,5 +25,6 @@ pub fn kev_0_8b_desired_state() -> BalancerDesiredState { n_gpu_layers: ALL_GPU_LAYERS, ..ModelRuntimeParameters::default() }, + ..BalancerDesiredState::default() } } diff --git a/paddler_tests/src/lib.rs b/paddler_tests/src/lib.rs index a9e261580..38c0533fe 100644 --- a/paddler_tests/src/lib.rs +++ b/paddler_tests/src/lib.rs @@ -1,7 +1,7 @@ pub mod batch_size_within_context; +pub mod cluster_without_agents_serving; pub mod conversation_with_a_tool_requiring_an_undeclared_property; pub mod decision_cluster_params; -pub mod decision_cluster_without_agents_params; pub mod desired_state_with_chat_template_override; pub mod desired_state_with_halved_image_resize; pub mod desired_state_with_multimodal_projection; diff --git a/paddler_tests/src/nomic_embed_desired_state_with_chat_template_override.rs b/paddler_tests/src/nomic_embed_desired_state_with_chat_template_override.rs index 24ee44c99..b34d93b85 100644 --- a/paddler_tests/src/nomic_embed_desired_state_with_chat_template_override.rs +++ b/paddler_tests/src/nomic_embed_desired_state_with_chat_template_override.rs @@ -1,14 +1,13 @@ -use anyhow::Result; - use paddler_messaging::balancer_desired_state::BalancerDesiredState; use paddler_messaging::chat_template::ChatTemplate; use paddler_test_cluster_harness::model_card::nomic_embed_text_v1_5::nomic_embed_text_v1_5; use crate::desired_state_with_chat_template_override::desired_state_with_chat_template_override; +#[must_use] pub fn nomic_embed_desired_state_with_chat_template_override( chat_template: ChatTemplate, -) -> Result { +) -> BalancerDesiredState { desired_state_with_chat_template_override( nomic_embed_text_v1_5().into_desired_state(), chat_template, diff --git a/paddler_tests/src/start_cluster.rs b/paddler_tests/src/start_cluster.rs index e326f80cc..3da63eb35 100644 --- a/paddler_tests/src/start_cluster.rs +++ b/paddler_tests/src/start_cluster.rs @@ -13,8 +13,6 @@ use paddler_balancer::resolved_socket_addr::ResolvedSocketAddr; use paddler_balancer_runner::balancer_runner::BalancerRunner; use paddler_balancer_runner::balancer_runner_config::BalancerRunnerConfig; use paddler_balancer_runner::balancer_runner_params::BalancerRunnerParams; -use paddler_balancer_runner::balancer_serving_mode::BalancerServingMode; -use paddler_messaging::inference_mode::InferenceMode; use paddler_state_database::state_database_type::StateDatabaseType; use paddler_test_cluster_harness::cluster::Cluster; use paddler_test_cluster_harness::cluster_params::ClusterParams; @@ -42,20 +40,6 @@ pub async fn start_cluster( let state_database_type = StateDatabaseType::from_str(&state_database_url) .context("failed to parse state_database_url")?; - let serving_mode = match desired_state.inference_mode() { - InferenceMode::Decision => BalancerServingMode::Decision { - typesafe_service_configuration: Some(CompatibilityServiceConfiguration { - addr: ResolvedSocketAddr::from(EPHEMERAL_LOOPBACK_ADDR), - }), - }, - InferenceMode::Embeddings => BalancerServingMode::Embeddings, - InferenceMode::TextGeneration => BalancerServingMode::TextGeneration { - openai_service_configuration: Some(CompatibilityServiceConfiguration { - addr: ResolvedSocketAddr::from(EPHEMERAL_LOOPBACK_ADDR), - }), - }, - }; - let balancer_runner = BalancerRunner::start(BalancerRunnerParams { runner_config: BalancerRunnerConfig { buffered_request_timeout, @@ -69,10 +53,15 @@ pub async fn start_cluster( cors_allowed_hosts: management_cors_allowed_hosts, }, max_buffered_requests, - serving_mode, + openai_service_configuration: Some(CompatibilityServiceConfiguration { + addr: ResolvedSocketAddr::from(EPHEMERAL_LOOPBACK_ADDR), + }), state_database_type, statsd_prefix: "paddler_tests_".to_owned(), statsd_service_configuration: None, + typesafe_service_configuration: Some(CompatibilityServiceConfiguration { + addr: ResolvedSocketAddr::from(EPHEMERAL_LOOPBACK_ADDR), + }), #[cfg(feature = "web_admin_panel")] web_admin_panel_service_configuration: None, }, diff --git a/paddler_tests/tests/a_cluster_serving_embeddings_refuses_http_token_generation_as_unavailable.rs b/paddler_tests/tests/a_cluster_serving_embeddings_refuses_http_token_generation_as_unavailable.rs new file mode 100644 index 000000000..8c51bbbc4 --- /dev/null +++ b/paddler_tests/tests/a_cluster_serving_embeddings_refuses_http_token_generation_as_unavailable.rs @@ -0,0 +1,71 @@ +use std::num::NonZeroU32; + +use tokio_util::sync::CancellationToken; + +use paddler_client::error::Error as ClientError; +use paddler_messaging::conversation_history::ConversationHistory; +use paddler_messaging::conversation_message::ConversationMessage; +use paddler_messaging::conversation_message_content::ConversationMessageContent; +use paddler_messaging::inference_mode::InferenceMode; +use paddler_messaging::request_params::continue_from_conversation_history_params::ContinueFromConversationHistoryParams; +use paddler_messaging::request_params::continue_from_raw_prompt_params::ContinueFromRawPromptParams; +use paddler_tests::cluster_without_agents_serving::cluster_without_agents_serving; +use paddler_tests::start_cluster::start_cluster; + +const REFUSAL: &str = "The cluster serves Embeddings, not TextGeneration"; + +fn is_refused_as_unavailable(rejection: &ClientError) -> bool { + matches!( + rejection, + ClientError::ServiceUnavailable { message, .. } if message == REFUSAL + ) +} + +#[tokio::test(flavor = "multi_thread")] +async fn a_cluster_serving_embeddings_refuses_http_token_generation_as_unavailable() { + let cluster = start_cluster(cluster_without_agents_serving(InferenceMode::Embeddings)) + .await + .expect("a balancer serving embeddings must start"); + + let raw_prompt_rejection = cluster + .client_inference + .post_continue_from_raw_prompt( + CancellationToken::new(), + &ContinueFromRawPromptParams { + grammar: None, + max_tokens: NonZeroU32::MIN, + raw_prompt: "hello".to_owned(), + }, + ) + .await + .err() + .expect("an embeddings cluster must refuse a raw prompt"); + let conversation_history_rejection = cluster + .client_inference + .post_continue_from_conversation_history( + CancellationToken::new(), + &ContinueFromConversationHistoryParams { + add_generation_prompt: true, + conversation_history: ConversationHistory::new(vec![ConversationMessage { + content: ConversationMessageContent::Text("hello".to_owned()), + role: "user".to_owned(), + }]), + enable_thinking: false, + grammar: None, + max_tokens: NonZeroU32::MIN, + parse_tool_calls: false, + tools: Vec::new(), + }, + ) + .await + .err() + .expect("an embeddings cluster must refuse a conversation history"); + + assert!(is_refused_as_unavailable(&raw_prompt_rejection)); + assert!(is_refused_as_unavailable(&conversation_history_rejection)); + + cluster + .shutdown() + .await + .expect("the cluster must shut down cleanly"); +} diff --git a/paddler_tests/tests/a_cluster_serving_text_generation_refuses_decisions_as_unavailable.rs b/paddler_tests/tests/a_cluster_serving_text_generation_refuses_decisions_as_unavailable.rs new file mode 100644 index 000000000..ac21913be --- /dev/null +++ b/paddler_tests/tests/a_cluster_serving_text_generation_refuses_decisions_as_unavailable.rs @@ -0,0 +1,45 @@ +use tokio_util::sync::CancellationToken; + +use paddler_client::error::Error as ClientError; +use paddler_messaging::decision_question::DecisionQuestion; +use paddler_messaging::inference_mode::InferenceMode; +use paddler_messaging::request_params::decide_params::raw_decide_params::RawDecideParams; +use paddler_tests::cluster_without_agents_serving::cluster_without_agents_serving; +use paddler_tests::start_cluster::start_cluster; + +#[tokio::test(flavor = "multi_thread")] +async fn a_cluster_serving_text_generation_refuses_decisions_as_unavailable() { + let cluster = start_cluster(cluster_without_agents_serving( + InferenceMode::TextGeneration, + )) + .await + .expect("a balancer serving text generation must start"); + + let decision_rejection = cluster + .client_inference + .post_decide( + CancellationToken::new(), + &RawDecideParams { + questions: vec![DecisionQuestion { + id: "paid".to_owned(), + instructions: "Was it paid?".to_owned(), + options: vec!["no".to_owned(), "yes".to_owned()], + }], + state: "The invoice was paid on time.".to_owned(), + }, + ) + .await + .err() + .expect("a text generation cluster must refuse a decision"); + + assert!(matches!( + decision_rejection, + ClientError::ServiceUnavailable { message, .. } + if message == "The cluster serves TextGeneration, not Decision" + )); + + cluster + .shutdown() + .await + .expect("the cluster must shut down cleanly"); +} diff --git a/paddler_tests/tests/a_text_generation_cluster_serves_no_embedding_route.rs b/paddler_tests/tests/a_cluster_serving_text_generation_refuses_embedding_batches_as_unavailable.rs similarity index 68% rename from paddler_tests/tests/a_text_generation_cluster_serves_no_embedding_route.rs rename to paddler_tests/tests/a_cluster_serving_text_generation_refuses_embedding_batches_as_unavailable.rs index 1884811d4..88f9e0199 100644 --- a/paddler_tests/tests/a_text_generation_cluster_serves_no_embedding_route.rs +++ b/paddler_tests/tests/a_cluster_serving_text_generation_refuses_embedding_batches_as_unavailable.rs @@ -1,20 +1,18 @@ -use http::StatusCode; use tokio_util::sync::CancellationToken; use paddler_client::error::Error as ClientError; use paddler_messaging::embedding_input_document::EmbeddingInputDocument; use paddler_messaging::embedding_normalization_method::EmbeddingNormalizationMethod; +use paddler_messaging::inference_mode::InferenceMode; use paddler_messaging::request_params::generate_embedding_batch_params::GenerateEmbeddingBatchParams; -use paddler_test_cluster_harness::cluster_params::ClusterParams; +use paddler_tests::cluster_without_agents_serving::cluster_without_agents_serving; use paddler_tests::start_cluster::start_cluster; #[tokio::test(flavor = "multi_thread")] -async fn a_text_generation_cluster_serves_no_embedding_route() { - let cluster = start_cluster(ClusterParams { - agents: Vec::new(), - wait_for_slots_ready: false, - ..ClusterParams::default() - }) +async fn a_cluster_serving_text_generation_refuses_embedding_batches_as_unavailable() { + let cluster = start_cluster(cluster_without_agents_serving( + InferenceMode::TextGeneration, + )) .await .expect("a balancer serving text generation must start"); @@ -32,11 +30,12 @@ async fn a_text_generation_cluster_serves_no_embedding_route() { ) .await .err() - .expect("a text generation cluster must not serve the embedding route"); + .expect("a text generation cluster must refuse an embedding batch"); assert!(matches!( embedding_rejection, - ClientError::UnexpectedResponseStatus { status, .. } if status == StatusCode::NOT_FOUND + ClientError::ServiceUnavailable { message, .. } + if message == "The cluster serves TextGeneration, not Embeddings" )); cluster diff --git a/paddler_tests/tests/agent_keeps_serving_in_flight_inference_when_the_desired_model_is_missing.rs b/paddler_tests/tests/agent_keeps_serving_in_flight_inference_when_the_desired_model_is_missing.rs index 9c19e437b..479866b22 100644 --- a/paddler_tests/tests/agent_keeps_serving_in_flight_inference_when_the_desired_model_is_missing.rs +++ b/paddler_tests/tests/agent_keeps_serving_in_flight_inference_when_the_desired_model_is_missing.rs @@ -9,7 +9,6 @@ use paddler_messaging::balancer_desired_state::BalancerDesiredState; use paddler_messaging::generation_finish::GenerationFinish; use paddler_messaging::inference_client::message::Message; use paddler_messaging::inference_client::response::Response; -use paddler_messaging::inference_mode::InferenceMode; use paddler_messaging::jsonrpc::response_envelope::ResponseEnvelope; use paddler_test_cluster_harness::agent_config::AgentConfig; use paddler_test_cluster_harness::collect_generated_tokens::collect_generated_tokens; @@ -50,7 +49,7 @@ async fn agent_keeps_serving_in_flight_inference_when_the_desired_model_is_missi CancellationToken::new(), &BalancerDesiredState { model: AgentDesiredModel::LocalToAgent("/nonexistent/model.gguf".to_owned()), - ..BalancerDesiredState::unconfigured(InferenceMode::TextGeneration) + ..BalancerDesiredState::default() }, ) .await diff --git a/paddler_tests/tests/agent_reports_chat_template_error_when_rendering_fails.rs b/paddler_tests/tests/agent_reports_chat_template_error_when_rendering_fails.rs index a94e1606e..10d75e9e8 100644 --- a/paddler_tests/tests/agent_reports_chat_template_error_when_rendering_fails.rs +++ b/paddler_tests/tests/agent_reports_chat_template_error_when_rendering_fails.rs @@ -27,8 +27,7 @@ async fn agent_reports_chat_template_error_when_rendering_fails() { ChatTemplate { content: "{{ raise_exception('conversations are not supported') }}".to_owned(), }, - ) - .expect("a text generation state must accept a chat template override"), + ), )), wait_for_slots_ready: true, ..ClusterParams::default() diff --git a/paddler_tests/tests/agent_reports_decode_failure_for_a_prompt_exceeding_a_non_causal_micro_batch.rs b/paddler_tests/tests/agent_reports_decode_failure_for_a_prompt_exceeding_a_non_causal_micro_batch.rs index ad2793581..0fcdda38d 100644 --- a/paddler_tests/tests/agent_reports_decode_failure_for_a_prompt_exceeding_a_non_causal_micro_batch.rs +++ b/paddler_tests/tests/agent_reports_decode_failure_for_a_prompt_exceeding_a_non_causal_micro_batch.rs @@ -24,8 +24,7 @@ async fn agent_reports_decode_failure_for_a_prompt_exceeding_a_non_causal_micro_ desired_state: ClusterDesiredState::Apply(Box::new( nomic_embed_desired_state_with_chat_template_override(ChatTemplate { content: "{{ messages[0].content }}".to_owned(), - }) - .expect("a text generation state must accept a chat template override"), + }), )), wait_for_slots_ready: true, ..ClusterParams::default() diff --git a/paddler_tests/tests/agent_reports_slot_cannot_start_for_excessive_slots.rs b/paddler_tests/tests/agent_reports_slot_cannot_start_for_excessive_slots.rs index 9bcd1e720..3b1ff0666 100644 --- a/paddler_tests/tests/agent_reports_slot_cannot_start_for_excessive_slots.rs +++ b/paddler_tests/tests/agent_reports_slot_cannot_start_for_excessive_slots.rs @@ -5,7 +5,6 @@ use paddler_inference_parameters::model_runtime_parameters::ModelRuntimeParamete use paddler_messaging::agent_desired_model::AgentDesiredModel; use paddler_messaging::agent_issue::AgentIssue; use paddler_messaging::balancer_desired_state::BalancerDesiredState; -use paddler_messaging::inference_mode::InferenceMode; use paddler_test_cluster_harness::agent_config::AgentConfig; use paddler_test_cluster_harness::cluster_desired_state::ClusterDesiredState; use paddler_test_cluster_harness::cluster_params::ClusterParams; @@ -27,7 +26,7 @@ async fn agent_reports_slot_cannot_start_for_excessive_slots() { ..ModelRuntimeParameters::default() }, model: AgentDesiredModel::HuggingFace(reference), - ..BalancerDesiredState::unconfigured(InferenceMode::TextGeneration) + ..BalancerDesiredState::default() })), wait_for_slots_ready: false, ..ClusterParams::default() diff --git a/paddler_tests/tests/agent_serves_request_being_prepared_when_state_change_begins.rs b/paddler_tests/tests/agent_serves_request_being_prepared_when_state_change_begins.rs index 71d79fecb..7e71128bf 100644 --- a/paddler_tests/tests/agent_serves_request_being_prepared_when_state_change_begins.rs +++ b/paddler_tests/tests/agent_serves_request_being_prepared_when_state_change_begins.rs @@ -86,8 +86,7 @@ async fn agent_serves_request_being_prepared_when_state_change_begins() { .client_management .put_balancer_desired_state( CancellationToken::new(), - &desired_state_with_halved_image_resize(initial_desired_state) - .expect("the desired state must be derivable"), + &desired_state_with_halved_image_resize(initial_desired_state), ) .await .expect("the balancer must accept the desired state"); diff --git a/paddler_tests/tests/agent_serves_request_sent_while_state_change_waits_for_in_flight_request.rs b/paddler_tests/tests/agent_serves_request_sent_while_state_change_waits_for_in_flight_request.rs index 013842c7f..402d21d05 100644 --- a/paddler_tests/tests/agent_serves_request_sent_while_state_change_waits_for_in_flight_request.rs +++ b/paddler_tests/tests/agent_serves_request_sent_while_state_change_waits_for_in_flight_request.rs @@ -51,8 +51,7 @@ async fn agent_serves_request_sent_while_state_change_waits_for_in_flight_reques .client_management .put_balancer_desired_state( CancellationToken::new(), - &desired_state_with_halved_image_resize(initial_desired_state) - .expect("the desired state must be derivable"), + &desired_state_with_halved_image_resize(initial_desired_state), ) .await .expect("the balancer must accept the desired state"); diff --git a/paddler_tests/tests/agent_shuts_down_while_state_change_waits_for_in_flight_request.rs b/paddler_tests/tests/agent_shuts_down_while_state_change_waits_for_in_flight_request.rs index 9f9c87b83..a6b4f2eee 100644 --- a/paddler_tests/tests/agent_shuts_down_while_state_change_waits_for_in_flight_request.rs +++ b/paddler_tests/tests/agent_shuts_down_while_state_change_waits_for_in_flight_request.rs @@ -38,8 +38,7 @@ async fn agent_shuts_down_while_state_change_waits_for_in_flight_request() { .client_management .put_balancer_desired_state( CancellationToken::new(), - &desired_state_with_halved_image_resize(initial_desired_state) - .expect("the desired state must be derivable"), + &desired_state_with_halved_image_resize(initial_desired_state), ) .await .expect("the balancer must accept the desired state"); diff --git a/paddler_tests/tests/agents_serve_the_inference_mode_the_cluster_switches_to_at_runtime.rs b/paddler_tests/tests/agents_serve_the_inference_mode_the_cluster_switches_to_at_runtime.rs new file mode 100644 index 000000000..ea0f531a2 --- /dev/null +++ b/paddler_tests/tests/agents_serve_the_inference_mode_the_cluster_switches_to_at_runtime.rs @@ -0,0 +1,73 @@ +#![cfg(feature = "tests_that_use_llms")] + +use std::num::NonZeroU32; + +use tokio_util::sync::CancellationToken; + +use paddler_messaging::balancer_desired_state::BalancerDesiredState; +use paddler_messaging::decision_result::DecisionResult; +use paddler_messaging::inference_mode::InferenceMode; +use paddler_messaging::request_params::continue_from_raw_prompt_params::ContinueFromRawPromptParams; +use paddler_tests::decision_cluster_params::DecisionClusterParams; +use paddler_tests::sample_decision::sample_decision; +use paddler_tests::start_decision_cluster::start_decision_cluster; + +const MAX_TOKENS: NonZeroU32 = NonZeroU32::new(16).unwrap(); + +#[tokio::test(flavor = "multi_thread")] +async fn agents_serve_the_inference_mode_the_cluster_switches_to_at_runtime() { + let mut cluster = start_decision_cluster(DecisionClusterParams::default()) + .await + .expect("a decision cluster must start"); + + let decided = cluster + .decide(CancellationToken::new(), &sample_decision()) + .await + .expect("the decision cluster must answer a decision"); + + assert!(matches!(decided.terminal_result, DecisionResult::Done(_))); + + let decision_desired_state = cluster + .client_management + .get_balancer_desired_state(CancellationToken::new()) + .await + .expect("the balancer must report its desired state"); + + cluster + .client_management + .put_balancer_desired_state( + CancellationToken::new(), + &BalancerDesiredState { + inference_mode: InferenceMode::TextGeneration, + ..decision_desired_state + }, + ) + .await + .expect("the balancer must switch the cluster to text generation"); + cluster + .wait_for_first_agent_to_serve(InferenceMode::TextGeneration) + .await + .expect("the agent must reload into text generation"); + + let generated = cluster + .continue_from_raw_prompt( + CancellationToken::new(), + &ContinueFromRawPromptParams { + grammar: None, + max_tokens: MAX_TOKENS, + raw_prompt: "The capital of France is".to_owned(), + }, + ) + .await + .expect("the switched cluster must generate tokens"); + + assert!(!generated.text.is_empty()); + generated + .summary() + .expect("the generation must finish with a summary"); + + cluster + .shutdown() + .await + .expect("the cluster must shut down cleanly"); +} diff --git a/paddler_tests/tests/an_embeddings_cluster_serves_no_token_generation_routes.rs b/paddler_tests/tests/an_embeddings_cluster_serves_no_token_generation_routes.rs deleted file mode 100644 index bb7db99a8..000000000 --- a/paddler_tests/tests/an_embeddings_cluster_serves_no_token_generation_routes.rs +++ /dev/null @@ -1,96 +0,0 @@ -use std::num::NonZeroU32; - -use http::StatusCode; -use tokio_tungstenite::tungstenite::Error as WebSocketError; -use tokio_util::sync::CancellationToken; - -use paddler_client::error::Error as ClientError; -use paddler_messaging::balancer_desired_state::BalancerDesiredState; -use paddler_messaging::conversation_history::ConversationHistory; -use paddler_messaging::conversation_message::ConversationMessage; -use paddler_messaging::conversation_message_content::ConversationMessageContent; -use paddler_messaging::inference_mode::InferenceMode; -use paddler_messaging::request_params::continue_from_conversation_history_params::ContinueFromConversationHistoryParams; -use paddler_messaging::request_params::continue_from_raw_prompt_params::ContinueFromRawPromptParams; -use paddler_test_cluster_harness::cluster_desired_state::ClusterDesiredState; -use paddler_test_cluster_harness::cluster_params::ClusterParams; -use paddler_tests::start_cluster::start_cluster; - -fn is_route_not_found(rejection: &ClientError) -> bool { - matches!( - rejection, - ClientError::UnexpectedResponseStatus { status, .. } if *status == StatusCode::NOT_FOUND - ) -} - -fn is_socket_upgrade_not_found(rejection: &ClientError) -> bool { - matches!( - rejection, - ClientError::WebSocket(WebSocketError::Http(response)) - if response.status() == StatusCode::NOT_FOUND - ) -} - -#[tokio::test(flavor = "multi_thread")] -async fn an_embeddings_cluster_serves_no_token_generation_routes() { - let cluster = start_cluster(ClusterParams { - agents: Vec::new(), - desired_state: ClusterDesiredState::Apply(Box::new(BalancerDesiredState::unconfigured( - InferenceMode::Embeddings, - ))), - wait_for_slots_ready: false, - ..ClusterParams::default() - }) - .await - .expect("a balancer serving embeddings must start"); - let conversation_history_params = ContinueFromConversationHistoryParams { - add_generation_prompt: true, - conversation_history: ConversationHistory::new(vec![ConversationMessage { - content: ConversationMessageContent::Text("hello".to_owned()), - role: "user".to_owned(), - }]), - enable_thinking: false, - grammar: None, - max_tokens: NonZeroU32::MIN, - parse_tool_calls: false, - tools: Vec::new(), - }; - - let raw_prompt_rejection = cluster - .client_inference - .post_continue_from_raw_prompt( - CancellationToken::new(), - &ContinueFromRawPromptParams { - grammar: None, - max_tokens: NonZeroU32::MIN, - raw_prompt: "hello".to_owned(), - }, - ) - .await - .err() - .expect("an embeddings cluster must not serve the raw prompt route"); - let conversation_history_rejection = cluster - .client_inference - .post_continue_from_conversation_history( - CancellationToken::new(), - &conversation_history_params, - ) - .await - .err() - .expect("an embeddings cluster must not serve the conversation history route"); - let inference_socket_rejection = cluster - .client_inference - .continue_from_conversation_history(CancellationToken::new(), conversation_history_params) - .await - .err() - .expect("an embeddings cluster must not serve the inference socket"); - - assert!(is_route_not_found(&raw_prompt_rejection)); - assert!(is_route_not_found(&conversation_history_rejection)); - assert!(is_socket_upgrade_not_found(&inference_socket_rejection)); - - cluster - .shutdown() - .await - .expect("the cluster must shut down cleanly"); -} 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 aea7162bc..7e1bda2f9 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 @@ -2,14 +2,13 @@ use std::num::NonZeroU32; -use futures_util::StreamExt as _; use tokio_util::sync::CancellationToken; -use paddler_messaging::inference_client::message::Message; use paddler_messaging::request_params::continue_from_raw_prompt_params::ContinueFromRawPromptParams; use paddler_test_cluster_harness::agent_config::AgentConfig; use paddler_test_cluster_harness::cluster_desired_state::ClusterDesiredState; use paddler_test_cluster_harness::cluster_params::ClusterParams; +use paddler_test_cluster_harness::collect_generated_tokens::collect_generated_tokens; use paddler_test_cluster_harness::model_card::qwen3_0_6b::qwen3_0_6b; use paddler_tests::start_cluster::start_cluster; @@ -25,7 +24,7 @@ async fn balancer_completes_buffered_request_after_agent_joins() { .await .expect("the cluster must start"); - let mut stream = cluster + let stream = cluster .continue_from_raw_prompt_stream( CancellationToken::new(), &ContinueFromRawPromptParams { @@ -49,21 +48,11 @@ async fn balancer_completes_buffered_request_after_agent_joins() { }) .expect("the additional agent must start"); - let message = stream - .next() + collect_generated_tokens(stream) .await - .expect("inference stream must yield a message after agent joins") - .expect("the message must be readable"); - - match message { - Message::Response(_) => {} - Message::Error(envelope) => { - panic!( - "expected a successful response, got error code {}: {}", - envelope.error.code, envelope.error.description - ); - } - } + .expect("the buffered request must be served once the agent joins") + .summary() + .expect("the buffered request must finish with a summary"); cluster .shutdown() diff --git a/paddler_tests/tests/balancer_inference_socket_recovers_from_embeddings_mode.rs b/paddler_tests/tests/balancer_inference_socket_recovers_from_embeddings_mode.rs new file mode 100644 index 000000000..8f9f9ed96 --- /dev/null +++ b/paddler_tests/tests/balancer_inference_socket_recovers_from_embeddings_mode.rs @@ -0,0 +1,119 @@ +#![cfg(feature = "tests_that_use_llms")] + +use std::num::NonZeroU32; + +use futures_util::StreamExt as _; +use tokio_util::sync::CancellationToken; + +use paddler_messaging::balancer_desired_state::BalancerDesiredState; +use paddler_messaging::generated_token_result::GeneratedTokenResult; +use paddler_messaging::inference_client::message::Message as InferenceMessage; +use paddler_messaging::inference_client::notification::Notification; +use paddler_messaging::inference_client::response::Response as InferenceResponse; +use paddler_messaging::inference_mode::InferenceMode; +use paddler_messaging::request_params::continue_from_raw_prompt_params::ContinueFromRawPromptParams; +use paddler_test_cluster_harness::agent_config::AgentConfig; +use paddler_test_cluster_harness::cluster_desired_state::ClusterDesiredState; +use paddler_test_cluster_harness::cluster_params::ClusterParams; +use paddler_test_cluster_harness::collect_generated_tokens::collect_generated_tokens; +use paddler_test_cluster_harness::model_card::qwen3_0_6b::qwen3_0_6b; +use paddler_tests::start_cluster::start_cluster; + +const MAX_TOKENS: NonZeroU32 = NonZeroU32::new(16).unwrap(); + +fn capital_of_france_prompt() -> ContinueFromRawPromptParams { + ContinueFromRawPromptParams { + grammar: None, + max_tokens: MAX_TOKENS, + raw_prompt: "The capital of France is".to_owned(), + } +} + +#[tokio::test(flavor = "multi_thread")] +async fn balancer_inference_socket_recovers_from_embeddings_mode() { + let generation_state = qwen3_0_6b().into_desired_state(); + let embeddings_state = BalancerDesiredState { + inference_mode: InferenceMode::Embeddings, + ..generation_state.clone() + }; + + let mut cluster = start_cluster(ClusterParams { + agents: AgentConfig::uniform(1, 1), + desired_state: ClusterDesiredState::Apply(Box::new(embeddings_state)), + wait_for_slots_ready: true, + ..ClusterParams::default() + }) + .await + .expect("a cluster serving embeddings must start"); + + let mut cluster_inference_mode_rx = cluster + .client_inference + .subscribe_to_cluster_inference_mode(); + + let refused_message = cluster + .client_inference + .continue_from_raw_prompt(CancellationToken::new(), capital_of_france_prompt()) + .await + .expect("the inference socket must accept a request while serving embeddings") + .next() + .await + .expect("the inference socket must answer while serving embeddings") + .expect("the answer must be readable"); + + assert!(matches!( + refused_message, + InferenceMessage::Response(envelope) + if matches!( + &envelope.response, + InferenceResponse::GeneratedToken(GeneratedTokenResult::InferenceModeMismatch(description)) + if description == "The cluster serves Embeddings, not TextGeneration" + ) + )); + + let connect_notification = cluster_inference_mode_rx + .recv() + .await + .expect("the client must be told on connect that the cluster serves embeddings"); + + assert_eq!( + connect_notification, + Notification::ClusterInferenceMode(InferenceMode::Embeddings) + ); + + cluster + .client_management + .put_balancer_desired_state(CancellationToken::new(), &generation_state) + .await + .expect("the balancer must accept the text generation state"); + + let recovery_notification = cluster_inference_mode_rx.recv().await.expect( + "the client must be told over the open connection that the cluster serves text generation", + ); + + assert_eq!( + recovery_notification, + Notification::ClusterInferenceMode(InferenceMode::TextGeneration) + ); + + cluster + .wait_for_first_agent_to_serve(InferenceMode::TextGeneration) + .await + .expect("the agent must reload into text generation"); + + let recovered = collect_generated_tokens( + cluster + .client_inference + .continue_from_raw_prompt(CancellationToken::new(), capital_of_france_prompt()) + .await + .expect("the recovered inference socket must accept a request"), + ) + .await + .expect("the recovered inference socket must stream tokens"); + + assert!(!recovered.text.is_empty()); + + cluster + .shutdown() + .await + .expect("the cluster must shut down cleanly"); +} 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 1d697c6a6..d865faa40 100644 --- a/paddler_tests/tests/balancer_memory_storage_persists_desired_state.rs +++ b/paddler_tests/tests/balancer_memory_storage_persists_desired_state.rs @@ -2,7 +2,6 @@ use tokio_util::sync::CancellationToken; use paddler_messaging::agent_desired_model::AgentDesiredModel; use paddler_messaging::balancer_desired_state::BalancerDesiredState; -use paddler_messaging::inference_mode::InferenceMode; use paddler_test_cluster_harness::cluster_desired_state::ClusterDesiredState; use paddler_test_cluster_harness::cluster_params::ClusterParams; use paddler_test_cluster_harness::model_card::ModelCard; @@ -15,7 +14,7 @@ async fn balancer_memory_storage_persists_desired_state() { let desired_state = BalancerDesiredState { model: AgentDesiredModel::HuggingFace(reference), - ..BalancerDesiredState::unconfigured(InferenceMode::TextGeneration) + ..BalancerDesiredState::default() }; let cluster = start_cluster(ClusterParams { 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 3c82e3588..ac54b4dcb 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 @@ -3,7 +3,6 @@ use tokio_util::sync::CancellationToken; use paddler_messaging::agent_desired_model::AgentDesiredModel; use paddler_messaging::balancer_desired_state::BalancerDesiredState; use paddler_messaging::chat_template::ChatTemplate; -use paddler_messaging::inference_mode::InferenceMode; use paddler_test_cluster_harness::cluster_desired_state::ClusterDesiredState; use paddler_test_cluster_harness::cluster_params::ClusterParams; use paddler_test_cluster_harness::model_card::ModelCard; @@ -21,13 +20,12 @@ async fn balancer_persists_chat_template_override_across_restart() { let desired_state = desired_state_with_chat_template_override( BalancerDesiredState { model: AgentDesiredModel::HuggingFace(reference), - ..BalancerDesiredState::unconfigured(InferenceMode::TextGeneration) + ..BalancerDesiredState::default() }, ChatTemplate { content: "{{ messages | tojson }}".to_owned(), }, - ) - .expect("a text generation state must accept a chat template override"); + ); let first_cluster = start_cluster(ClusterParams { agents: Vec::new(), @@ -48,7 +46,7 @@ async fn balancer_persists_chat_template_override_across_restart() { agents: Vec::new(), wait_for_slots_ready: false, state_database_url: database.url.clone(), - desired_state: ClusterDesiredState::KeepStored(InferenceMode::TextGeneration), + desired_state: ClusterDesiredState::KeepStored, ..ClusterParams::default() }) .await 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 6fb2e596e..e16aba469 100644 --- a/paddler_tests/tests/balancer_persists_desired_state_across_restart.rs +++ b/paddler_tests/tests/balancer_persists_desired_state_across_restart.rs @@ -2,7 +2,6 @@ use tokio_util::sync::CancellationToken; use paddler_messaging::agent_desired_model::AgentDesiredModel; use paddler_messaging::balancer_desired_state::BalancerDesiredState; -use paddler_messaging::inference_mode::InferenceMode; use paddler_test_cluster_harness::cluster_desired_state::ClusterDesiredState; use paddler_test_cluster_harness::cluster_params::ClusterParams; use paddler_test_cluster_harness::model_card::ModelCard; @@ -18,7 +17,7 @@ async fn balancer_persists_desired_state_across_restart() { let desired_state = BalancerDesiredState { model: AgentDesiredModel::HuggingFace(reference), - ..BalancerDesiredState::unconfigured(InferenceMode::TextGeneration) + ..BalancerDesiredState::default() }; let first_cluster = start_cluster(ClusterParams { @@ -40,7 +39,7 @@ async fn balancer_persists_desired_state_across_restart() { agents: Vec::new(), wait_for_slots_ready: false, state_database_url: database.url.clone(), - desired_state: ClusterDesiredState::KeepStored(InferenceMode::TextGeneration), + desired_state: ClusterDesiredState::KeepStored, ..ClusterParams::default() }) .await 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 166d9494f..ec8a6e04d 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 @@ -2,7 +2,6 @@ use tokio_util::sync::CancellationToken; use paddler_messaging::agent_desired_model::AgentDesiredModel; use paddler_messaging::balancer_desired_state::BalancerDesiredState; -use paddler_messaging::inference_mode::InferenceMode; use paddler_test_cluster_harness::cluster_desired_state::ClusterDesiredState; use paddler_test_cluster_harness::cluster_params::ClusterParams; use paddler_test_cluster_harness::model_card::ModelCard; @@ -23,11 +22,10 @@ async fn balancer_persists_huggingface_mmproj_in_desired_state() { let desired_state = desired_state_with_multimodal_projection( BalancerDesiredState { model: AgentDesiredModel::HuggingFace(primary_reference), - ..BalancerDesiredState::unconfigured(InferenceMode::TextGeneration) + ..BalancerDesiredState::default() }, AgentDesiredModel::HuggingFace(mmproj_reference), - ) - .expect("a text generation state must accept a multimodal projection"); + ); let cluster = start_cluster(ClusterParams { agents: Vec::new(), 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 c844b6867..15887fe3b 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 @@ -2,7 +2,6 @@ use tokio_util::sync::CancellationToken; use paddler_messaging::agent_desired_model::AgentDesiredModel; use paddler_messaging::balancer_desired_state::BalancerDesiredState; -use paddler_messaging::inference_mode::InferenceMode; use paddler_test_cluster_harness::cluster_desired_state::ClusterDesiredState; use paddler_test_cluster_harness::cluster_params::ClusterParams; use paddler_test_cluster_harness::model_card::ModelCard; @@ -19,11 +18,10 @@ async fn balancer_persists_local_mmproj_path_in_desired_state() { let desired_state = desired_state_with_multimodal_projection( BalancerDesiredState { model: AgentDesiredModel::HuggingFace(reference), - ..BalancerDesiredState::unconfigured(InferenceMode::TextGeneration) + ..BalancerDesiredState::default() }, AgentDesiredModel::LocalToAgent(local_mmproj_path), - ) - .expect("a text generation state must accept a multimodal projection"); + ); let cluster = start_cluster(ClusterParams { agents: Vec::new(), 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 50ec40951..9469752cb 100644 --- a/paddler_tests/tests/balancer_persists_model_switch_in_storage.rs +++ b/paddler_tests/tests/balancer_persists_model_switch_in_storage.rs @@ -2,7 +2,6 @@ use tokio_util::sync::CancellationToken; use paddler_messaging::agent_desired_model::AgentDesiredModel; use paddler_messaging::balancer_desired_state::BalancerDesiredState; -use paddler_messaging::inference_mode::InferenceMode; use paddler_test_cluster_harness::cluster_desired_state::ClusterDesiredState; use paddler_test_cluster_harness::cluster_params::ClusterParams; use paddler_test_cluster_harness::model_card::ModelCard; @@ -18,7 +17,7 @@ async fn balancer_persists_model_switch_in_storage() { let initial_state = BalancerDesiredState { model: AgentDesiredModel::HuggingFace(reference), - ..BalancerDesiredState::unconfigured(InferenceMode::TextGeneration) + ..BalancerDesiredState::default() }; let cluster = start_cluster(ClusterParams { @@ -41,7 +40,7 @@ async fn balancer_persists_model_switch_in_storage() { let switched_state = BalancerDesiredState { model: AgentDesiredModel::LocalToAgent("/tmp/alternative-model.gguf".to_owned()), - ..BalancerDesiredState::unconfigured(InferenceMode::TextGeneration) + ..BalancerDesiredState::default() }; cluster diff --git a/paddler_tests/tests/balancer_persists_url_model_in_desired_state.rs b/paddler_tests/tests/balancer_persists_url_model_in_desired_state.rs index 70162a0fc..f45086347 100644 --- a/paddler_tests/tests/balancer_persists_url_model_in_desired_state.rs +++ b/paddler_tests/tests/balancer_persists_url_model_in_desired_state.rs @@ -2,7 +2,6 @@ use tokio_util::sync::CancellationToken; use paddler_messaging::agent_desired_model::AgentDesiredModel; use paddler_messaging::balancer_desired_state::BalancerDesiredState; -use paddler_messaging::inference_mode::InferenceMode; use paddler_messaging::url_model_reference::UrlModelReference; use paddler_test_cluster_harness::cluster_desired_state::ClusterDesiredState; use paddler_test_cluster_harness::cluster_params::ClusterParams; @@ -19,7 +18,7 @@ async fn balancer_persists_url_model_in_desired_state() { model: AgentDesiredModel::Url(UrlModelReference { url: configured_url.clone(), }), - ..BalancerDesiredState::unconfigured(InferenceMode::TextGeneration) + ..BalancerDesiredState::default() })), ..ClusterParams::default() }) diff --git a/paddler_tests/tests/balancer_refuses_a_stored_desired_state_with_an_ambiguous_parameter.rs b/paddler_tests/tests/balancer_refuses_a_stored_desired_state_with_an_ambiguous_parameter.rs index 12685b81d..f8ad50543 100644 --- a/paddler_tests/tests/balancer_refuses_a_stored_desired_state_with_an_ambiguous_parameter.rs +++ b/paddler_tests/tests/balancer_refuses_a_stored_desired_state_with_an_ambiguous_parameter.rs @@ -5,7 +5,6 @@ use tokio::fs::write; use paddler_balancer_runner::balancer_runner_error::BalancerRunnerError; use paddler_messaging::balancer_desired_state::BalancerDesiredState; -use paddler_messaging::inference_mode::InferenceMode; use paddler_state_database::state_database_error::StateDatabaseError; use paddler_test_cluster_harness::cluster_desired_state::ClusterDesiredState; use paddler_test_cluster_harness::cluster_params::ClusterParams; @@ -15,13 +14,10 @@ use paddler_tests::start_cluster::start_cluster; #[tokio::test(flavor = "multi_thread")] async fn balancer_refuses_a_stored_desired_state_with_an_ambiguous_parameter() { let database = StateDatabaseFile::new().expect("the state database file must be created"); - let mut stored_desired_state = to_value(BalancerDesiredState::unconfigured( - InferenceMode::TextGeneration, - )) - .expect("the value must serialize"); + let mut stored_desired_state = + to_value(BalancerDesiredState::default()).expect("the value must serialize"); - stored_desired_state["inference_settings"]["TextGeneration"]["multimodal"]["image_resize_to_fit"] = - json!(0); + stored_desired_state["text_generation"]["multimodal"]["image_resize_to_fit"] = json!(0); write( &database.path, @@ -38,7 +34,7 @@ async fn balancer_refuses_a_stored_desired_state_with_an_ambiguous_parameter() { agents: Vec::new(), wait_for_slots_ready: false, state_database_url: database.url.clone(), - desired_state: ClusterDesiredState::KeepStored(InferenceMode::TextGeneration), + desired_state: ClusterDesiredState::KeepStored, ..ClusterParams::default() }) .await diff --git a/paddler_tests/tests/balancer_rejects_a_second_registration_under_a_registered_agent_id.rs b/paddler_tests/tests/balancer_rejects_a_second_registration_under_a_registered_agent_id.rs index 983e4ea96..c06c5d5e2 100644 --- a/paddler_tests/tests/balancer_rejects_a_second_registration_under_a_registered_agent_id.rs +++ b/paddler_tests/tests/balancer_rejects_a_second_registration_under_a_registered_agent_id.rs @@ -1,8 +1,6 @@ use tokio_tungstenite::tungstenite::protocol::frame::coding::CloseCode; use tokio_util::sync::CancellationToken; -use paddler_messaging::balancer_desired_state::BalancerDesiredState; -use paddler_messaging::inference_mode::InferenceMode; use paddler_messaging::management_socket::balancer::notification::Notification as ManagementJsonRpcNotification; use paddler_messaging::management_socket::balancer::notification_params::register_agent_params::RegisterAgentParams; use paddler_messaging::slot_aggregated_status_snapshot::SlotAggregatedStatusSnapshot; @@ -16,9 +14,7 @@ use paddler_tests::start_cluster::start_cluster; async fn balancer_rejects_a_second_registration_under_a_registered_agent_id() { let cluster = start_cluster(ClusterParams { agents: AgentConfig::uniform(1, 1), - desired_state: ClusterDesiredState::Apply(Box::new(BalancerDesiredState::unconfigured( - InferenceMode::TextGeneration, - ))), + desired_state: ClusterDesiredState::Apply(Box::default()), wait_for_slots_ready: false, ..ClusterParams::default() }) diff --git a/paddler_tests/tests/balancer_rejects_a_status_update_from_a_socket_that_did_not_register.rs b/paddler_tests/tests/balancer_rejects_a_status_update_from_a_socket_that_did_not_register.rs index fb70ee55e..ac5fd06de 100644 --- a/paddler_tests/tests/balancer_rejects_a_status_update_from_a_socket_that_did_not_register.rs +++ b/paddler_tests/tests/balancer_rejects_a_status_update_from_a_socket_that_did_not_register.rs @@ -2,7 +2,6 @@ use tokio_tungstenite::tungstenite::protocol::frame::coding::CloseCode; use tokio_util::sync::CancellationToken; use paddler_messaging::agent_status::AgentStatus; -use paddler_messaging::balancer_desired_state::BalancerDesiredState; use paddler_messaging::management_socket::balancer::notification::Notification as ManagementJsonRpcNotification; use paddler_messaging::management_socket::balancer::notification_params::update_agent_status_params::UpdateAgentStatusParams; use paddler_messaging::slot_aggregated_status_snapshot::SlotAggregatedStatusSnapshot; @@ -11,15 +10,12 @@ use paddler_test_cluster_harness::cluster_params::ClusterParams; use paddler_test_cluster_harness::raw_agent_socket::RawAgentSocket; use paddler_tests::start_cluster::start_cluster; use paddler_test_cluster_harness::cluster_desired_state::ClusterDesiredState; -use paddler_messaging::inference_mode::InferenceMode; #[tokio::test(flavor = "multi_thread")] async fn balancer_rejects_a_status_update_from_a_socket_that_did_not_register() { let cluster = start_cluster(ClusterParams { agents: AgentConfig::uniform(1, 1), - desired_state: ClusterDesiredState::Apply(Box::new(BalancerDesiredState::unconfigured( - InferenceMode::TextGeneration, - ))), + desired_state: ClusterDesiredState::Apply(Box::default()), wait_for_slots_ready: false, ..ClusterParams::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 44514c263..c66154058 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 @@ -4,7 +4,6 @@ use paddler_messaging::agent_desired_model::AgentDesiredModel; use paddler_messaging::agent_issue::AgentIssue; use paddler_messaging::balancer_desired_state::BalancerDesiredState; use paddler_messaging::chat_template::ChatTemplate; -use paddler_messaging::inference_mode::InferenceMode; use paddler_test_cluster_harness::model_card::ModelCard; use paddler_test_cluster_harness::model_card::qwen3_0_6b::qwen3_0_6b; use paddler_tests::desired_state_with_chat_template_override::desired_state_with_chat_template_override; @@ -13,20 +12,18 @@ use paddler_tests::start_single_agent_cluster_with_desired_state::start_single_a #[tokio::test(flavor = "multi_thread")] async fn balancer_reports_chat_template_does_not_compile_for_invalid_jinja() { let ModelCard { reference } = qwen3_0_6b(); - let mut cluster = start_single_agent_cluster_with_desired_state( - desired_state_with_chat_template_override( + let mut cluster = + start_single_agent_cluster_with_desired_state(desired_state_with_chat_template_override( BalancerDesiredState { model: AgentDesiredModel::HuggingFace(reference), - ..BalancerDesiredState::unconfigured(InferenceMode::TextGeneration) + ..BalancerDesiredState::default() }, ChatTemplate { content: "{{invalid jinja template".to_owned(), }, - ) - .expect("a text generation state must accept a chat template override"), - ) - .await - .expect("a single-agent cluster must start"); + )) + .await + .expect("a single-agent cluster must start"); cluster .wait_for_first_agent_issue(|issue| { diff --git a/paddler_tests/tests/balancer_reports_chat_template_does_not_compile_recovers_when_template_replaced.rs b/paddler_tests/tests/balancer_reports_chat_template_does_not_compile_recovers_when_template_replaced.rs index 63774f11f..4e1b9f761 100644 --- a/paddler_tests/tests/balancer_reports_chat_template_does_not_compile_recovers_when_template_replaced.rs +++ b/paddler_tests/tests/balancer_reports_chat_template_does_not_compile_recovers_when_template_replaced.rs @@ -6,7 +6,6 @@ use paddler_messaging::agent_desired_model::AgentDesiredModel; use paddler_messaging::agent_issue::AgentIssue; use paddler_messaging::balancer_desired_state::BalancerDesiredState; use paddler_messaging::chat_template::ChatTemplate; -use paddler_messaging::inference_mode::InferenceMode; use paddler_test_cluster_harness::model_card::ModelCard; use paddler_test_cluster_harness::model_card::qwen3_0_6b::qwen3_0_6b; use paddler_tests::desired_state_with_chat_template_override::desired_state_with_chat_template_override; @@ -15,20 +14,18 @@ use paddler_tests::start_single_agent_cluster_with_desired_state::start_single_a #[tokio::test(flavor = "multi_thread")] async fn balancer_reports_chat_template_does_not_compile_recovers_when_template_replaced() { let ModelCard { reference } = qwen3_0_6b(); - let mut cluster = start_single_agent_cluster_with_desired_state( - desired_state_with_chat_template_override( + let mut cluster = + start_single_agent_cluster_with_desired_state(desired_state_with_chat_template_override( BalancerDesiredState { model: AgentDesiredModel::HuggingFace(reference.clone()), - ..BalancerDesiredState::unconfigured(InferenceMode::TextGeneration) + ..BalancerDesiredState::default() }, ChatTemplate { content: "{{invalid jinja template".to_owned(), }, - ) - .expect("a text generation state must accept a chat template override"), - ) - .await - .expect("a single-agent cluster must start"); + )) + .await + .expect("a single-agent cluster must start"); cluster .wait_for_first_agent_issue(|issue| { @@ -44,14 +41,13 @@ async fn balancer_reports_chat_template_does_not_compile_recovers_when_template_ &desired_state_with_chat_template_override( BalancerDesiredState { model: AgentDesiredModel::HuggingFace(reference), - ..BalancerDesiredState::unconfigured(InferenceMode::TextGeneration) + ..BalancerDesiredState::default() }, ChatTemplate { content: "{% for message in messages %}{{ message.content }}{% endfor %}" .to_owned(), }, - ) - .expect("a text generation state must accept a chat template override"), + ), ) .await .expect("the balancer must accept the desired state with a valid template"); diff --git a/paddler_tests/tests/balancer_reports_download_server_denied_access.rs b/paddler_tests/tests/balancer_reports_download_server_denied_access.rs index 19e7e49b3..bc52e276e 100644 --- a/paddler_tests/tests/balancer_reports_download_server_denied_access.rs +++ b/paddler_tests/tests/balancer_reports_download_server_denied_access.rs @@ -5,7 +5,6 @@ use paddler_local_http_fixture::local_http_fixture::LocalHttpFixture; use paddler_messaging::agent_desired_model::AgentDesiredModel; use paddler_messaging::agent_issue::AgentIssue; use paddler_messaging::balancer_desired_state::BalancerDesiredState; -use paddler_messaging::inference_mode::InferenceMode; use paddler_messaging::url_model_reference::UrlModelReference; use paddler_tests::start_single_agent_cluster_with_desired_state::start_single_agent_cluster_with_desired_state; @@ -19,7 +18,7 @@ async fn balancer_reports_download_server_denied_access() { model: AgentDesiredModel::Url(UrlModelReference { url: model_url.clone(), }), - ..BalancerDesiredState::unconfigured(InferenceMode::TextGeneration) + ..BalancerDesiredState::default() }) .await .expect("a single-agent cluster must start"); diff --git a/paddler_tests/tests/balancer_reports_download_server_errored.rs b/paddler_tests/tests/balancer_reports_download_server_errored.rs index 97fc21281..dafb0f100 100644 --- a/paddler_tests/tests/balancer_reports_download_server_errored.rs +++ b/paddler_tests/tests/balancer_reports_download_server_errored.rs @@ -5,7 +5,6 @@ use paddler_local_http_fixture::local_http_fixture::LocalHttpFixture; use paddler_messaging::agent_desired_model::AgentDesiredModel; use paddler_messaging::agent_issue::AgentIssue; use paddler_messaging::balancer_desired_state::BalancerDesiredState; -use paddler_messaging::inference_mode::InferenceMode; use paddler_messaging::url_model_reference::UrlModelReference; use paddler_tests::start_single_agent_cluster_with_desired_state::start_single_agent_cluster_with_desired_state; @@ -20,7 +19,7 @@ async fn balancer_reports_download_server_errored() { model: AgentDesiredModel::Url(UrlModelReference { url: model_url.clone(), }), - ..BalancerDesiredState::unconfigured(InferenceMode::TextGeneration) + ..BalancerDesiredState::default() }) .await .expect("a single-agent cluster must start"); diff --git a/paddler_tests/tests/balancer_reports_download_server_is_unreachable.rs b/paddler_tests/tests/balancer_reports_download_server_is_unreachable.rs index 90a27cd6c..c76684d70 100644 --- a/paddler_tests/tests/balancer_reports_download_server_is_unreachable.rs +++ b/paddler_tests/tests/balancer_reports_download_server_is_unreachable.rs @@ -1,7 +1,6 @@ use paddler_messaging::agent_desired_model::AgentDesiredModel; use paddler_messaging::agent_issue::AgentIssue; use paddler_messaging::balancer_desired_state::BalancerDesiredState; -use paddler_messaging::inference_mode::InferenceMode; use paddler_messaging::url_model_reference::UrlModelReference; use paddler_tests::start_single_agent_cluster_with_desired_state::start_single_agent_cluster_with_desired_state; @@ -12,7 +11,7 @@ async fn balancer_reports_download_server_is_unreachable() { model: AgentDesiredModel::Url(UrlModelReference { url: model_url.clone(), }), - ..BalancerDesiredState::unconfigured(InferenceMode::TextGeneration) + ..BalancerDesiredState::default() }) .await .expect("a single-agent cluster must start"); diff --git a/paddler_tests/tests/balancer_reports_download_url_is_malformed.rs b/paddler_tests/tests/balancer_reports_download_url_is_malformed.rs index bb6297d6e..9383a8389 100644 --- a/paddler_tests/tests/balancer_reports_download_url_is_malformed.rs +++ b/paddler_tests/tests/balancer_reports_download_url_is_malformed.rs @@ -1,7 +1,6 @@ use paddler_messaging::agent_desired_model::AgentDesiredModel; use paddler_messaging::agent_issue::AgentIssue; use paddler_messaging::balancer_desired_state::BalancerDesiredState; -use paddler_messaging::inference_mode::InferenceMode; use paddler_messaging::url_model_reference::UrlModelReference; use paddler_tests::start_single_agent_cluster_with_desired_state::start_single_agent_cluster_with_desired_state; @@ -12,7 +11,7 @@ async fn balancer_reports_download_url_is_malformed() { model: AgentDesiredModel::Url(UrlModelReference { url: model_url.clone(), }), - ..BalancerDesiredState::unconfigured(InferenceMode::TextGeneration) + ..BalancerDesiredState::default() }) .await .expect("a single-agent cluster must start"); 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 6dbcf7187..9e8bb4632 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 @@ -4,7 +4,6 @@ use paddler_messaging::agent_desired_model::AgentDesiredModel; use paddler_messaging::agent_issue::AgentIssue; use paddler_messaging::balancer_desired_state::BalancerDesiredState; use paddler_messaging::huggingface_model_reference::HuggingFaceModelReference; -use paddler_messaging::inference_mode::InferenceMode; use paddler_tests::start_single_agent_cluster_with_desired_state::start_single_agent_cluster_with_desired_state; #[tokio::test(flavor = "multi_thread")] @@ -15,7 +14,7 @@ async fn balancer_reports_huggingface_model_does_not_exist() { repo_id: "nonexistent-org/nonexistent-model-gguf".to_owned(), revision: "main".to_owned(), }), - ..BalancerDesiredState::unconfigured(InferenceMode::TextGeneration) + ..BalancerDesiredState::default() }) .await .expect("a single-agent cluster must start"); 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 786d78bc8..fd6c82ead 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 @@ -5,7 +5,6 @@ use tempfile::NamedTempFile; use paddler_messaging::agent_desired_model::AgentDesiredModel; use paddler_messaging::agent_issue::AgentIssue; use paddler_messaging::balancer_desired_state::BalancerDesiredState; -use paddler_messaging::inference_mode::InferenceMode; use paddler_tests::start_single_agent_cluster_with_desired_state::start_single_agent_cluster_with_desired_state; #[tokio::test(flavor = "multi_thread")] @@ -23,7 +22,7 @@ async fn balancer_reports_model_cannot_be_loaded_for_corrupt_file() { .to_owned(); let mut cluster = start_single_agent_cluster_with_desired_state(BalancerDesiredState { model: AgentDesiredModel::LocalToAgent(corrupt_model_path.clone()), - ..BalancerDesiredState::unconfigured(InferenceMode::TextGeneration) + ..BalancerDesiredState::default() }) .await .expect("a single-agent cluster must start"); 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 8758476f4..6c9c0444c 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 @@ -1,11 +1,8 @@ -use paddler_inference_parameters::embedding_parameters::EmbeddingParameters; -use paddler_inference_parameters::model_runtime_parameters::ModelRuntimeParameters; use paddler_messaging::agent_desired_model::AgentDesiredModel; use paddler_messaging::agent_issue::AgentIssue; use paddler_messaging::balancer_desired_state::BalancerDesiredState; -use paddler_messaging::balancer_inference_settings::BalancerInferenceSettings; -use paddler_messaging::balancer_text_generation_settings::BalancerTextGenerationSettings; use paddler_messaging::decision_settings::DecisionSettings; +use paddler_messaging::inference_mode::InferenceMode; use paddler_test_cluster_harness::agent_config::AgentConfig; use paddler_test_cluster_harness::cluster_desired_state::ClusterDesiredState; use paddler_test_cluster_harness::cluster_params::ClusterParams; @@ -23,19 +20,22 @@ async fn balancer_reports_model_cannot_be_loaded_for_invalid_gguf() { ) .to_owned(); - for inference_settings in [ - BalancerInferenceSettings::Decision(DecisionSettings { - pointer_head: AgentDesiredModel::LocalToAgent(pointer_head_path_on_agent.clone()), - }), - BalancerInferenceSettings::Embeddings(EmbeddingParameters::default()), - BalancerInferenceSettings::TextGeneration(BalancerTextGenerationSettings::default()), + for inference_mode in [ + InferenceMode::Decision, + InferenceMode::Embeddings, + InferenceMode::TextGeneration, ] { let mut cluster = start_cluster(ClusterParams { agents: AgentConfig::uniform(1, DECISION_SLOTS), desired_state: ClusterDesiredState::Apply(Box::new(BalancerDesiredState { - inference_settings, + decision: DecisionSettings { + pointer_head: AgentDesiredModel::LocalToAgent( + pointer_head_path_on_agent.clone(), + ), + }, + inference_mode, model: AgentDesiredModel::LocalToAgent(model_path_on_agent.clone()), - model_runtime_parameters: ModelRuntimeParameters::default(), + ..BalancerDesiredState::default() })), wait_for_slots_ready: false, ..ClusterParams::default() diff --git a/paddler_tests/tests/balancer_reports_model_does_not_exist_at_url.rs b/paddler_tests/tests/balancer_reports_model_does_not_exist_at_url.rs index e61ec5fd4..0eed090f8 100644 --- a/paddler_tests/tests/balancer_reports_model_does_not_exist_at_url.rs +++ b/paddler_tests/tests/balancer_reports_model_does_not_exist_at_url.rs @@ -5,7 +5,6 @@ use paddler_local_http_fixture::local_http_fixture::LocalHttpFixture; use paddler_messaging::agent_desired_model::AgentDesiredModel; use paddler_messaging::agent_issue::AgentIssue; use paddler_messaging::balancer_desired_state::BalancerDesiredState; -use paddler_messaging::inference_mode::InferenceMode; use paddler_messaging::url_model_reference::UrlModelReference; use paddler_tests::start_single_agent_cluster_with_desired_state::start_single_agent_cluster_with_desired_state; @@ -19,7 +18,7 @@ async fn balancer_reports_model_does_not_exist_at_url() { model: AgentDesiredModel::Url(UrlModelReference { url: model_url.clone(), }), - ..BalancerDesiredState::unconfigured(InferenceMode::TextGeneration) + ..BalancerDesiredState::default() }) .await .expect("a single-agent cluster must start"); 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 eb644dcee..fa7c6a52c 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 @@ -1,7 +1,6 @@ use paddler_messaging::agent_desired_model::AgentDesiredModel; use paddler_messaging::agent_issue::AgentIssue; use paddler_messaging::balancer_desired_state::BalancerDesiredState; -use paddler_messaging::inference_mode::InferenceMode; use paddler_tests::start_single_agent_cluster_with_desired_state::start_single_agent_cluster_with_desired_state; #[tokio::test(flavor = "multi_thread")] @@ -9,7 +8,7 @@ async fn balancer_reports_model_file_does_not_exist() { let model_path_on_agent = "/nonexistent/model.gguf".to_owned(); let mut cluster = start_single_agent_cluster_with_desired_state(BalancerDesiredState { model: AgentDesiredModel::LocalToAgent(model_path_on_agent.clone()), - ..BalancerDesiredState::unconfigured(InferenceMode::TextGeneration) + ..BalancerDesiredState::default() }) .await .expect("a single-agent cluster must start"); diff --git a/paddler_tests/tests/balancer_reports_multimodal_projection_cannot_be_loaded_for_invalid_file.rs b/paddler_tests/tests/balancer_reports_multimodal_projection_cannot_be_loaded_for_invalid_file.rs index 169f04e8a..67275a332 100644 --- a/paddler_tests/tests/balancer_reports_multimodal_projection_cannot_be_loaded_for_invalid_file.rs +++ b/paddler_tests/tests/balancer_reports_multimodal_projection_cannot_be_loaded_for_invalid_file.rs @@ -3,7 +3,6 @@ use paddler_messaging::agent_desired_model::AgentDesiredModel; use paddler_messaging::agent_issue::AgentIssue; use paddler_messaging::balancer_desired_state::BalancerDesiredState; -use paddler_messaging::inference_mode::InferenceMode; use paddler_test_cluster_harness::model_card::ModelCard; use paddler_test_cluster_harness::model_card::qwen3_0_6b::qwen3_0_6b; use paddler_tests::desired_state_with_multimodal_projection::desired_state_with_multimodal_projection; @@ -17,18 +16,16 @@ async fn balancer_reports_multimodal_projection_cannot_be_loaded_for_invalid_fil "/../fixtures/invalid_mmproj.gguf" ) .to_owned(); - let mut cluster = start_single_agent_cluster_with_desired_state( - desired_state_with_multimodal_projection( + let mut cluster = + start_single_agent_cluster_with_desired_state(desired_state_with_multimodal_projection( BalancerDesiredState { model: AgentDesiredModel::HuggingFace(reference), - ..BalancerDesiredState::unconfigured(InferenceMode::TextGeneration) + ..BalancerDesiredState::default() }, AgentDesiredModel::LocalToAgent(projection_path.clone()), - ) - .expect("a text generation state must accept a multimodal projection"), - ) - .await - .expect("a single-agent cluster must start"); + )) + .await + .expect("a single-agent cluster must start"); cluster .wait_for_first_agent_issue(|issue| { diff --git a/paddler_tests/tests/balancer_reports_multimodal_projection_cannot_be_loaded_for_missing_file.rs b/paddler_tests/tests/balancer_reports_multimodal_projection_cannot_be_loaded_for_missing_file.rs index 1ed3a2947..7039eaa0c 100644 --- a/paddler_tests/tests/balancer_reports_multimodal_projection_cannot_be_loaded_for_missing_file.rs +++ b/paddler_tests/tests/balancer_reports_multimodal_projection_cannot_be_loaded_for_missing_file.rs @@ -3,7 +3,6 @@ use paddler_messaging::agent_desired_model::AgentDesiredModel; use paddler_messaging::agent_issue::AgentIssue; use paddler_messaging::balancer_desired_state::BalancerDesiredState; -use paddler_messaging::inference_mode::InferenceMode; use paddler_test_cluster_harness::model_card::ModelCard; use paddler_test_cluster_harness::model_card::qwen3_0_6b::qwen3_0_6b; use paddler_tests::desired_state_with_multimodal_projection::desired_state_with_multimodal_projection; @@ -13,18 +12,16 @@ use paddler_tests::start_single_agent_cluster_with_desired_state::start_single_a async fn balancer_reports_multimodal_projection_cannot_be_loaded_for_missing_file() { let ModelCard { reference } = qwen3_0_6b(); let projection_path = "/nonexistent/projection.bin".to_owned(); - let mut cluster = start_single_agent_cluster_with_desired_state( - desired_state_with_multimodal_projection( + let mut cluster = + start_single_agent_cluster_with_desired_state(desired_state_with_multimodal_projection( BalancerDesiredState { model: AgentDesiredModel::HuggingFace(reference), - ..BalancerDesiredState::unconfigured(InferenceMode::TextGeneration) + ..BalancerDesiredState::default() }, AgentDesiredModel::LocalToAgent(projection_path.clone()), - ) - .expect("a text generation state must accept a multimodal projection"), - ) - .await - .expect("a single-agent cluster must start"); + )) + .await + .expect("a single-agent cluster must start"); cluster .wait_for_first_agent_issue(|issue| { 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 512f39e8f..6f6e24d7d 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 @@ -3,7 +3,6 @@ use paddler_messaging::agent_desired_model::AgentDesiredModel; use paddler_messaging::agent_issue::AgentIssue; use paddler_messaging::balancer_desired_state::BalancerDesiredState; -use paddler_messaging::inference_mode::InferenceMode; use paddler_test_cluster_harness::model_card::ModelCard; use paddler_test_cluster_harness::model_card::nomic_embed_text_v1_5::nomic_embed_text_v1_5; use paddler_tests::start_single_agent_cluster_with_desired_state::start_single_agent_cluster_with_desired_state; @@ -13,7 +12,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_single_agent_cluster_with_desired_state(BalancerDesiredState { model: AgentDesiredModel::HuggingFace(reference), - ..BalancerDesiredState::unconfigured(InferenceMode::TextGeneration) + ..BalancerDesiredState::default() }) .await .expect("a single-agent cluster must start"); 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 d5f865202..48e4abdc6 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 @@ -47,14 +47,10 @@ async fn balancer_returns_503_when_request_buffering_disabled() { .expect("inference stream must yield a message") .expect("the message must be readable"); - match message { - Message::Error(envelope) => { - assert_eq!(envelope.error.code, 503); - } - Message::Response(_) => { - panic!("expected buffer overflow error, got success"); - } - } + assert!(matches!( + message, + Message::Error(envelope) if envelope.error.code == 503 + )); cluster .shutdown() 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 afa176273..8e0c2ec25 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 @@ -44,14 +44,10 @@ async fn balancer_returns_504_when_inference_item_timeout_is_zero() { .expect("inference stream must yield a message") .expect("the message must be readable"); - match message { - Message::Error(envelope) => { - assert_eq!(envelope.error.code, 504); - } - Message::Response(_) => { - panic!("expected timeout error, got success"); - } - } + assert!(matches!( + message, + Message::Error(envelope) if envelope.error.code == 504 + )); cluster .shutdown() diff --git a/paddler_tests/tests/balancer_serves_inference_over_websocket.rs b/paddler_tests/tests/balancer_serves_inference_over_websocket.rs index 2e50fd7de..44c0f15ea 100644 --- a/paddler_tests/tests/balancer_serves_inference_over_websocket.rs +++ b/paddler_tests/tests/balancer_serves_inference_over_websocket.rs @@ -7,7 +7,9 @@ use tokio_util::sync::CancellationToken; use paddler_messaging::inference_client::message::Message as InferenceMessage; use paddler_messaging::inference_client::response::Response; +use paddler_messaging::jsonrpc::response_envelope::ResponseEnvelope; use paddler_messaging::request_params::continue_from_raw_prompt_params::ContinueFromRawPromptParams; +use paddler_messaging::streamable_result::StreamableResult as _; use paddler_test_cluster_harness::agent_config::AgentConfig; use paddler_tests::start_cluster_with_qwen3::start_cluster_with_qwen3; @@ -17,7 +19,7 @@ async fn balancer_serves_inference_over_websocket() { .await .expect("the cluster must start"); - let mut stream = cluster + let stream = cluster .client_inference .continue_from_raw_prompt( CancellationToken::new(), @@ -30,30 +32,32 @@ async fn balancer_serves_inference_over_websocket() { .await .expect("the inference request must be accepted"); - let mut token_count: usize = 0; - - while let Some(message_result) = stream.next().await { - match message_result.expect("the message must be readable") { - InferenceMessage::Response(envelope) => match envelope.response { - Response::GeneratedToken(generated_token_result) => { - if generated_token_result.is_token() { - token_count += 1; - } - } - Response::Decision(_) | Response::Embedding(_) => { - panic!("inference over websocket produced an unexpected response variant") - } - }, - InferenceMessage::Error(envelope) => { - panic!( - "inference over websocket failed: code {}, description {:?}", - envelope.error.code, envelope.error.description - ) - } - } - } - - assert!(token_count > 0); + let messages: Vec = stream + .map(|message_result| message_result.expect("the message must be readable")) + .collect() + .await; + + assert!(messages.iter().all(|message| matches!( + message, + InferenceMessage::Response(ResponseEnvelope { + response: Response::GeneratedToken(_), + .. + }) + ))); + assert!(messages.iter().any(|message| matches!( + message, + InferenceMessage::Response(ResponseEnvelope { + response: Response::GeneratedToken(token_result), + .. + }) if token_result.is_token() + ))); + assert!(matches!( + messages.last(), + Some(InferenceMessage::Response(ResponseEnvelope { + response: Response::GeneratedToken(token_result), + .. + })) if token_result.is_done() + )); cluster .shutdown() 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 92a077184..3a5767a5e 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 @@ -11,6 +11,7 @@ use paddler_messaging::request_params::continue_from_raw_prompt_params::Continue use paddler_test_cluster_harness::agent_config::AgentConfig; use paddler_test_cluster_harness::cluster_desired_state::ClusterDesiredState; use paddler_test_cluster_harness::cluster_params::ClusterParams; +use paddler_test_cluster_harness::collect_generated_tokens::collect_generated_tokens; use paddler_test_cluster_harness::model_card::qwen3_0_6b::qwen3_0_6b; use paddler_tests::start_cluster::start_cluster; @@ -45,14 +46,10 @@ async fn balancer_serves_request_after_agent_with_capacity_registers() { .expect("inference stream must yield a message") .expect("the message must be readable"); - match early_message { - Message::Error(envelope) => { - assert_eq!(envelope.error.code, 504); - } - Message::Response(_) => { - panic!("expected timeout before agent registered"); - } - } + assert!(matches!( + early_message, + Message::Error(envelope) if envelope.error.code == 504 + )); cluster .spawn_additional_agent(&AgentConfig { @@ -73,7 +70,7 @@ async fn balancer_serves_request_after_agent_with_capacity_registers() { .await .expect("agent should register with 4 slots"); - let mut later_stream = cluster + let later_stream = cluster .continue_from_raw_prompt_stream( CancellationToken::new(), &ContinueFromRawPromptParams { @@ -85,20 +82,11 @@ async fn balancer_serves_request_after_agent_with_capacity_registers() { .await .expect("the inference request must be accepted"); - let later_message = later_stream - .next() + collect_generated_tokens(later_stream) .await - .expect("inference stream must yield a message") - .expect("the message must be readable"); - - match later_message { - Message::Error(envelope) => { - panic!( - "a request must be served once an agent with capacity registered, got {envelope:?}" - ); - } - Message::Response(_) => {} - } + .expect("a request must be served once an agent with capacity registered") + .summary() + .expect("the served request must finish with a summary"); cluster .shutdown() 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 968869668..c1d38376b 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 @@ -67,8 +67,7 @@ async fn chat_template_drains_in_flight_inference_before_swap() { &desired_state_with_chat_template_override( initial_desired_state, swapped_template.clone(), - ) - .expect("a text generation state must accept a chat template override"), + ), ) .await .expect("the balancer must accept the desired state"); 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 04f023d62..83d3e23de 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 @@ -19,8 +19,7 @@ async fn chat_template_override_applied_to_embedding_model() { agents: AgentConfig::uniform(1, 1), wait_for_slots_ready: false, desired_state: ClusterDesiredState::Apply(Box::new( - nomic_embed_desired_state_with_chat_template_override(chat_template.clone()) - .expect("a text generation state must accept a chat template override"), + nomic_embed_desired_state_with_chat_template_override(chat_template.clone()), )), ..ClusterParams::default() }) 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 f6f39a41c..44c03bf6e 100644 --- a/paddler_tests/tests/chat_template_override_replaces_model_builtin.rs +++ b/paddler_tests/tests/chat_template_override_replaces_model_builtin.rs @@ -12,7 +12,6 @@ use paddler_messaging::chat_template::ChatTemplate; use paddler_messaging::conversation_history::ConversationHistory; use paddler_messaging::conversation_message::ConversationMessage; use paddler_messaging::conversation_message_content::ConversationMessageContent; -use paddler_messaging::inference_mode::InferenceMode; use paddler_messaging::request_params::continue_from_conversation_history_params::ContinueFromConversationHistoryParams; use paddler_test_cluster_harness::agent_config::AgentConfig; use paddler_test_cluster_harness::cluster_desired_state::ClusterDesiredState; @@ -41,11 +40,10 @@ async fn chat_template_override_replaces_model_builtin() { ..ModelRuntimeParameters::default() }, model: AgentDesiredModel::HuggingFace(reference), - ..BalancerDesiredState::unconfigured(InferenceMode::TextGeneration) + ..BalancerDesiredState::default() }, chat_template.clone(), - ) - .expect("a text generation state must accept a chat template override"), + ), )), ..ClusterParams::default() }) 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 03253e5a5..749a62172 100644 --- a/paddler_tests/tests/chat_template_swaps_between_inference_calls.rs +++ b/paddler_tests/tests/chat_template_swaps_between_inference_calls.rs @@ -68,8 +68,7 @@ async fn chat_template_swaps_between_inference_calls() { &desired_state_with_chat_template_override( qwen3_desired_state(), swapped_template.clone(), - ) - .expect("a text generation state must accept a chat template override"), + ), ) .await .expect("the balancer must accept the desired state"); diff --git a/paddler_tests/tests/client_reports_service_unavailable_for_an_embedding_batch_without_agents.rs b/paddler_tests/tests/client_reports_service_unavailable_for_an_embedding_batch_without_agents.rs deleted file mode 100644 index 83878b640..000000000 --- a/paddler_tests/tests/client_reports_service_unavailable_for_an_embedding_batch_without_agents.rs +++ /dev/null @@ -1,51 +0,0 @@ -use tokio_util::sync::CancellationToken; - -use paddler_client::error::Error; -use paddler_messaging::balancer_desired_state::BalancerDesiredState; -use paddler_messaging::embedding_input_document::EmbeddingInputDocument; -use paddler_messaging::embedding_normalization_method::EmbeddingNormalizationMethod; -use paddler_messaging::inference_mode::InferenceMode; -use paddler_messaging::request_params::generate_embedding_batch_params::GenerateEmbeddingBatchParams; -use paddler_test_cluster_harness::cluster_desired_state::ClusterDesiredState; -use paddler_test_cluster_harness::cluster_params::ClusterParams; -use paddler_tests::start_cluster::start_cluster; - -#[tokio::test(flavor = "multi_thread")] -async fn client_reports_service_unavailable_for_an_embedding_batch_without_agents() { - let cluster = start_cluster(ClusterParams { - agents: Vec::new(), - desired_state: ClusterDesiredState::Apply(Box::new(BalancerDesiredState::unconfigured( - InferenceMode::Embeddings, - ))), - wait_for_slots_ready: false, - ..ClusterParams::default() - }) - .await - .expect("a cluster without agents must start"); - - let rejection = cluster - .client_inference - .post_generate_embedding_batch( - CancellationToken::new(), - &GenerateEmbeddingBatchParams { - input_batch: vec![EmbeddingInputDocument { - content: "Hello world".to_owned(), - id: "doc-1".to_owned(), - }], - normalization_method: EmbeddingNormalizationMethod::None, - }, - ) - .await - .err() - .expect("the balancer must reject an embedding batch while no agent can serve it"); - - assert!(matches!( - rejection, - Error::ServiceUnavailable { message, .. } if message == "No agents are currently connected" - )); - - cluster - .shutdown() - .await - .expect("the cluster must shut down cleanly"); -} diff --git a/paddler_tests/tests/cluster_harness_reports_an_agent_that_reports_issues_while_starting.rs b/paddler_tests/tests/cluster_harness_reports_an_agent_that_reports_issues_while_starting.rs index 9bb9dbd7b..b4f5df7bb 100644 --- a/paddler_tests/tests/cluster_harness_reports_an_agent_that_reports_issues_while_starting.rs +++ b/paddler_tests/tests/cluster_harness_reports_an_agent_that_reports_issues_while_starting.rs @@ -4,7 +4,6 @@ use paddler_messaging::agent_desired_model::AgentDesiredModel; use paddler_messaging::agent_issue::AgentIssue; use paddler_messaging::agent_issue_params::model_path::ModelPath; use paddler_messaging::balancer_desired_state::BalancerDesiredState; -use paddler_messaging::inference_mode::InferenceMode; use paddler_test_cluster_harness::agent_config::AgentConfig; use paddler_test_cluster_harness::cluster_desired_state::ClusterDesiredState; use paddler_test_cluster_harness::cluster_harness_error::ClusterHarnessError; @@ -23,7 +22,7 @@ async fn cluster_harness_reports_an_agent_that_reports_issues_while_starting() { }], desired_state: ClusterDesiredState::Apply(Box::new(BalancerDesiredState { model: AgentDesiredModel::LocalToAgent(MISSING_MODEL_PATH.to_owned()), - ..BalancerDesiredState::unconfigured(InferenceMode::TextGeneration) + ..BalancerDesiredState::default() })), wait_for_slots_ready: true, ..ClusterParams::default() diff --git a/paddler_tests/tests/decision_without_questions_is_rejected_with_bad_request.rs b/paddler_tests/tests/decision_without_questions_is_rejected_with_bad_request.rs index 2c5f5d1ad..3c54e9d5a 100644 --- a/paddler_tests/tests/decision_without_questions_is_rejected_with_bad_request.rs +++ b/paddler_tests/tests/decision_without_questions_is_rejected_with_bad_request.rs @@ -3,22 +3,16 @@ use tokio_util::sync::CancellationToken; use paddler_client::error::Error as ClientError; use paddler_messaging::inference_mode::InferenceMode; use paddler_messaging::request_params::decide_params::raw_decide_params::RawDecideParams; -use paddler_test_cluster_harness::cluster_desired_state::ClusterDesiredState; -use paddler_test_cluster_harness::cluster_params::ClusterParams; +use paddler_tests::cluster_without_agents_serving::cluster_without_agents_serving; use paddler_tests::start_cluster::start_cluster; const BAD_REQUEST: u16 = 400; #[tokio::test(flavor = "multi_thread")] async fn decision_without_questions_is_rejected_with_bad_request() { - let cluster = start_cluster(ClusterParams { - agents: Vec::new(), - desired_state: ClusterDesiredState::KeepStored(InferenceMode::Decision), - wait_for_slots_ready: false, - ..ClusterParams::default() - }) - .await - .expect("the cluster must start"); + let cluster = start_cluster(cluster_without_agents_serving(InferenceMode::Decision)) + .await + .expect("the cluster must start"); let rejection = cluster .client_inference diff --git a/paddler_tests/tests/half_closed_embedding_batch_client_releases_the_buffered_request.rs b/paddler_tests/tests/half_closed_embedding_batch_client_releases_the_buffered_request.rs index 5d02a5058..8bcfcd5da 100644 --- a/paddler_tests/tests/half_closed_embedding_batch_client_releases_the_buffered_request.rs +++ b/paddler_tests/tests/half_closed_embedding_batch_client_releases_the_buffered_request.rs @@ -14,9 +14,10 @@ use paddler_tests::start_cluster::start_cluster; async fn half_closed_embedding_batch_client_releases_the_buffered_request() { let mut cluster = start_cluster(ClusterParams { agents: Vec::new(), - desired_state: ClusterDesiredState::Apply(Box::new(BalancerDesiredState::unconfigured( - InferenceMode::Embeddings, - ))), + desired_state: ClusterDesiredState::Apply(Box::new(BalancerDesiredState { + inference_mode: InferenceMode::Embeddings, + ..BalancerDesiredState::default() + })), wait_for_slots_ready: false, ..ClusterParams::default() }) diff --git a/paddler_tests/tests/inference_socket_answers_a_conversation_history_request_with_an_inference_mode_mismatch_while_serving_embeddings.rs b/paddler_tests/tests/inference_socket_answers_a_conversation_history_request_with_an_inference_mode_mismatch_while_serving_embeddings.rs new file mode 100644 index 000000000..ef17a2d63 --- /dev/null +++ b/paddler_tests/tests/inference_socket_answers_a_conversation_history_request_with_an_inference_mode_mismatch_while_serving_embeddings.rs @@ -0,0 +1,63 @@ +use std::num::NonZeroU32; + +use futures_util::StreamExt as _; +use tokio_util::sync::CancellationToken; + +use paddler_messaging::conversation_history::ConversationHistory; +use paddler_messaging::conversation_message::ConversationMessage; +use paddler_messaging::conversation_message_content::ConversationMessageContent; +use paddler_messaging::generated_token_result::GeneratedTokenResult; +use paddler_messaging::inference_client::message::Message as InferenceClientMessage; +use paddler_messaging::inference_client::response::Response as InferenceClientResponse; +use paddler_messaging::inference_mode::InferenceMode; +use paddler_messaging::request_params::continue_from_conversation_history_params::ContinueFromConversationHistoryParams; +use paddler_tests::cluster_without_agents_serving::cluster_without_agents_serving; +use paddler_tests::start_cluster::start_cluster; + +#[tokio::test(flavor = "multi_thread")] +async fn inference_socket_answers_a_conversation_history_request_with_an_inference_mode_mismatch_while_serving_embeddings() + { + let cluster = start_cluster(cluster_without_agents_serving(InferenceMode::Embeddings)) + .await + .expect("a balancer serving embeddings must start"); + + let answer = cluster + .client_inference + .continue_from_conversation_history( + CancellationToken::new(), + ContinueFromConversationHistoryParams { + add_generation_prompt: true, + conversation_history: ConversationHistory::new(vec![ConversationMessage { + content: ConversationMessageContent::Text("What is the weather?".to_owned()), + role: "user".to_owned(), + }]), + enable_thinking: false, + grammar: None, + max_tokens: NonZeroU32::MIN, + parse_tool_calls: false, + tools: Vec::new(), + }, + ) + .await + .expect("the inference socket must accept a conversation history request") + .next() + .await + .expect("the inference socket must answer the conversation history request") + .expect("the answer must be readable"); + + assert!(matches!( + answer, + InferenceClientMessage::Response(envelope) + if matches!( + &envelope.response, + InferenceClientResponse::GeneratedToken( + GeneratedTokenResult::InferenceModeMismatch(description) + ) if description == "The cluster serves Embeddings, not TextGeneration" + ) + )); + + cluster + .shutdown() + .await + .expect("the cluster must shut down cleanly"); +} diff --git a/paddler_tests/tests/inference_socket_connection_rejects_requests_after_the_balancer_closes_it.rs b/paddler_tests/tests/inference_socket_connection_rejects_requests_after_the_balancer_closes_it.rs index 8aa36a927..efc6994c2 100644 --- a/paddler_tests/tests/inference_socket_connection_rejects_requests_after_the_balancer_closes_it.rs +++ b/paddler_tests/tests/inference_socket_connection_rejects_requests_after_the_balancer_closes_it.rs @@ -1,3 +1,4 @@ +use tokio::sync::broadcast; use tokio::task::yield_now; use paddler_client::error::Error; @@ -14,11 +15,13 @@ async fn inference_socket_connection_rejects_requests_after_the_balancer_closes_ }) .await .expect("a cluster without agents must start"); + let (notification_tx, _notification_rx) = broadcast::channel(1); let connection = Connection::connect( cluster .balancer .inference_base_url() .expect("the inference service must have a base URL"), + notification_tx, ) .await .expect("the connection to the balancer must open"); diff --git a/paddler_tests/tests/inference_socket_notifies_clients_only_when_the_cluster_inference_mode_changes.rs b/paddler_tests/tests/inference_socket_notifies_clients_only_when_the_cluster_inference_mode_changes.rs new file mode 100644 index 000000000..6d0dec556 --- /dev/null +++ b/paddler_tests/tests/inference_socket_notifies_clients_only_when_the_cluster_inference_mode_changes.rs @@ -0,0 +1,104 @@ +use std::num::NonZeroU32; +use std::num::NonZeroUsize; + +use futures_util::StreamExt as _; +use tokio_util::sync::CancellationToken; + +use paddler_inference_parameters::embedding_parameters::EmbeddingParameters; +use paddler_messaging::balancer_desired_state::BalancerDesiredState; +use paddler_messaging::inference_client::notification::Notification; +use paddler_messaging::inference_mode::InferenceMode; +use paddler_messaging::request_params::continue_from_raw_prompt_params::ContinueFromRawPromptParams; +use paddler_tests::cluster_without_agents_serving::cluster_without_agents_serving; +use paddler_tests::start_cluster::start_cluster; + +fn desired_state_serving(inference_mode: InferenceMode) -> BalancerDesiredState { + BalancerDesiredState { + inference_mode, + ..BalancerDesiredState::default() + } +} + +#[tokio::test(flavor = "multi_thread")] +async fn inference_socket_notifies_clients_only_when_the_cluster_inference_mode_changes() { + let cluster = start_cluster(cluster_without_agents_serving(InferenceMode::Embeddings)) + .await + .expect("a balancer serving embeddings must start"); + let mut cluster_inference_mode_rx = cluster + .client_inference + .subscribe_to_cluster_inference_mode(); + + cluster + .client_inference + .continue_from_raw_prompt( + CancellationToken::new(), + ContinueFromRawPromptParams { + grammar: None, + max_tokens: NonZeroU32::MIN, + raw_prompt: "open the inference socket".to_owned(), + }, + ) + .await + .expect("the inference socket must accept a request while serving embeddings") + .next() + .await + .expect("the inference socket must answer while serving embeddings") + .expect("the answer must be readable"); + + let connect_notification = cluster_inference_mode_rx + .recv() + .await + .expect("the client must be told on connect which inference mode the cluster serves"); + + cluster + .client_management + .put_balancer_desired_state( + CancellationToken::new(), + &BalancerDesiredState { + embeddings: EmbeddingParameters { + embedding_batch_size: NonZeroUsize::MIN, + ..EmbeddingParameters::default() + }, + ..desired_state_serving(InferenceMode::Embeddings) + }, + ) + .await + .expect("the balancer must apply a state that keeps serving embeddings"); + + let mut notification_after = async |inference_mode: InferenceMode| { + cluster + .client_management + .put_balancer_desired_state( + CancellationToken::new(), + &desired_state_serving(inference_mode), + ) + .await + .expect("the balancer must apply the desired state"); + + cluster_inference_mode_rx + .recv() + .await + .expect("the client must receive the inference mode change") + }; + + let text_generation_notification = notification_after(InferenceMode::TextGeneration).await; + let decision_notification = notification_after(InferenceMode::Decision).await; + + assert_eq!( + [ + connect_notification, + text_generation_notification, + decision_notification, + ], + [ + Notification::ClusterInferenceMode(InferenceMode::Embeddings), + Notification::ClusterInferenceMode(InferenceMode::TextGeneration), + Notification::ClusterInferenceMode(InferenceMode::Decision), + ] + ); + + cluster + .shutdown() + .await + .expect("the cluster must shut down cleanly"); +} diff --git a/paddler_tests/tests/main.rs b/paddler_tests/tests/main.rs index 6559a8566..c7294644b 100644 --- a/paddler_tests/tests/main.rs +++ b/paddler_tests/tests/main.rs @@ -1,4 +1,6 @@ -mod a_text_generation_cluster_serves_no_embedding_route; +mod a_cluster_serving_embeddings_refuses_http_token_generation_as_unavailable; +mod a_cluster_serving_text_generation_refuses_decisions_as_unavailable; +mod a_cluster_serving_text_generation_refuses_embedding_batches_as_unavailable; mod agent_chunks_embedding_batch_larger_than_slot_count; mod agent_completes_generation_with_adequate_n_batch; mod agent_conversation_accepts_empty_tools_list; @@ -72,7 +74,7 @@ mod agent_streams_tokens_held_by_the_bare_json_probe_at_end_of_generation; mod agent_text_only_model_rejects_image_input; mod agent_tokenizes_a_raw_prompt_opening_with_the_bos_token_with_a_single_bos_token; mod agent_tokenizes_gemma_3_chat_prompt_with_a_single_bos_token; -mod an_embeddings_cluster_serves_no_token_generation_routes; +mod agents_serve_the_inference_mode_the_cluster_switches_to_at_runtime; mod balancer_answers_bad_gateway_when_an_agent_disconnects_before_answering; mod balancer_answers_gateway_timeout_when_an_agent_never_answers; mod balancer_closes_an_agent_socket_that_sends_a_reserved_opcode_frame_as_a_protocol_violation; @@ -82,6 +84,7 @@ mod balancer_completes_buffered_request_after_agent_joins; mod balancer_ends_an_in_flight_generation_with_a_shutdown_error; mod balancer_inference_health_returns_ok; mod balancer_inference_service_replies_with_configured_cors_origin; +mod balancer_inference_socket_recovers_from_embeddings_mode; mod balancer_keeps_an_agent_connected_after_it_answers_an_unknown_request; mod balancer_management_service_replies_with_configured_cors_origin; mod balancer_memory_storage_persists_desired_state; @@ -125,7 +128,6 @@ mod chat_template_override_applied_to_embedding_model; mod chat_template_override_replaces_model_builtin; mod chat_template_swaps_between_inference_calls; mod client_inference_reconnects_after_the_balancer_closes_its_socket; -mod client_reports_service_unavailable_for_an_embedding_batch_without_agents; mod client_reports_unexpected_status_for_model_metadata_of_an_unknown_agent; mod cluster_harness_reports_an_agent_that_reports_issues_while_starting; mod cluster_harness_reports_an_awaited_agent_that_leaves_the_pool; @@ -172,6 +174,7 @@ mod half_closed_embedding_batch_client_releases_the_buffered_request; mod half_closed_http_client_releases_the_buffered_request; mod http_inference_cancellation_ends_the_client_stream; mod http_inference_cancellation_releases_the_agent_slot; +mod inference_socket_answers_a_conversation_history_request_with_an_inference_mode_mismatch_while_serving_embeddings; mod inference_socket_answers_a_ping_with_a_pong; mod inference_socket_answers_an_undeserializable_request_with_an_error; mod inference_socket_burst_cancellation_serves_every_buffered_request; @@ -182,6 +185,7 @@ mod inference_socket_connection_rejects_requests_after_the_balancer_closes_it; mod inference_socket_duplicate_request_id_is_answered_with_an_error; mod inference_socket_keeps_serving_after_a_binary_frame; mod inference_socket_keeps_serving_after_malformed_json; +mod inference_socket_notifies_clients_only_when_the_cluster_inference_mode_changes; mod inference_socket_partial_cancellation_serves_only_the_freed_slots; mod inference_socket_pool_rejects_a_second_request_under_an_in_flight_id; mod inference_socket_rejects_a_request_without_a_websocket_upgrade; @@ -202,6 +206,7 @@ mod management_two_agents_stream_subscribers_receive_slot_usage_changes; mod mistral3_internal_endpoint_emits_reasoning_tokens; mod mistral3_internal_endpoint_emits_reasoning_tokens_for_image_request; mod mistral3_internal_endpoint_emits_tool_call_parsed_event; +mod openai_chat_completion_is_unavailable_while_the_cluster_serves_another_inference_mode; mod openai_chat_completion_non_streaming_conforms_to_schema; mod openai_chat_completion_non_streaming_emits_tool_calls_for_function_tool; mod openai_chat_completion_non_streaming_half_closed_client_releases_the_buffered_request; @@ -221,6 +226,7 @@ mod openai_chat_completion_streaming_usage_breakdown_with_thinking; mod openai_chat_completion_with_reasoning_effort_none_generates_no_reasoning_tokens; mod openai_health_returns_ok; mod openai_responses_half_closed_client_releases_the_buffered_request; +mod openai_responses_is_unavailable_while_the_cluster_serves_another_inference_mode; mod openai_responses_non_streaming_conforms_to_schema; mod openai_responses_non_streaming_reports_incomplete_status_at_max_output_tokens; mod openai_responses_non_streaming_returns_text_and_usage; @@ -247,12 +253,15 @@ mod qwen3_internal_endpoint_emits_tool_call_tokens; mod qwen3_internal_endpoint_tools_without_parse_flag_emit_only_raw_tokens; mod qwen3_internal_endpoint_with_thinking_disabled_emits_no_reasoning_tokens; mod qwen3_internal_endpoint_with_thinking_enabled_emits_reasoning_tokens; -mod run_with_test_cluster_exposes_only_the_services_its_cluster_serves; +mod raw_inference_socket_reports_a_socket_closed_before_any_answer; +mod run_with_test_cluster_exports_the_url_of_every_service; mod smolvlm2_generates_tokens_from_image_input; mod typesafe_health_returns_ok; +mod typesafe_models_is_unavailable_while_the_cluster_serves_another_inference_mode; mod typesafe_models_lists_the_model_the_cluster_serves; mod typesafe_service_lets_browsers_send_and_read_typesafe_headers; mod typesafe_system_one_answers_questions_in_their_order; mod typesafe_system_one_answers_with_the_decision_model; +mod typesafe_system_one_is_unavailable_while_the_cluster_serves_another_inference_mode; mod typesafe_system_one_rejects_malformed_requests_as_unprocessable; mod typesafe_system_one_reports_balancer_failures_with_their_http_status; diff --git a/paddler_tests/tests/management_returns_balancer_desired_state_that_was_put.rs b/paddler_tests/tests/management_returns_balancer_desired_state_that_was_put.rs index 1b01f78df..9446bb8ae 100644 --- a/paddler_tests/tests/management_returns_balancer_desired_state_that_was_put.rs +++ b/paddler_tests/tests/management_returns_balancer_desired_state_that_was_put.rs @@ -6,9 +6,7 @@ use tokio_util::sync::CancellationToken; use paddler_client::client_management::ClientManagement; use paddler_inference_parameters::sampling_parameters::SamplingParameters; use paddler_messaging::balancer_desired_state::BalancerDesiredState; -use paddler_messaging::balancer_inference_settings::BalancerInferenceSettings; use paddler_messaging::balancer_text_generation_settings::BalancerTextGenerationSettings; -use paddler_messaging::inference_mode::InferenceMode; use paddler_test_cluster_harness::cluster_params::ClusterParams; use paddler_tests::start_cluster::start_cluster; @@ -36,17 +34,15 @@ async fn management_returns_balancer_desired_state_that_was_put() { get_balancer_desired_state(&cluster.client_management) .await .expect("the balancer must report its desired state"), - BalancerDesiredState::unconfigured(InferenceMode::TextGeneration) + BalancerDesiredState::default() ); let desired_state = BalancerDesiredState { - inference_settings: BalancerInferenceSettings::TextGeneration( - BalancerTextGenerationSettings { - sampling_parameters: SamplingParameters::deterministic(), - ..BalancerTextGenerationSettings::default() - }, - ), - ..BalancerDesiredState::unconfigured(InferenceMode::TextGeneration) + text_generation: BalancerTextGenerationSettings { + sampling_parameters: SamplingParameters::deterministic(), + ..BalancerTextGenerationSettings::default() + }, + ..BalancerDesiredState::default() }; cluster diff --git a/paddler_tests/tests/management_serves_the_applicable_state_of_the_desired_state.rs b/paddler_tests/tests/management_serves_the_applicable_state_of_the_desired_state.rs index 5b28a64c0..e0687363a 100644 --- a/paddler_tests/tests/management_serves_the_applicable_state_of_the_desired_state.rs +++ b/paddler_tests/tests/management_serves_the_applicable_state_of_the_desired_state.rs @@ -2,7 +2,6 @@ use tokio_util::sync::CancellationToken; use paddler_messaging::agent_desired_state::AgentDesiredState; use paddler_messaging::balancer_desired_state::BalancerDesiredState; -use paddler_messaging::inference_mode::InferenceMode; use paddler_test_cluster_harness::cluster_params::ClusterParams; use paddler_tests::start_cluster::start_cluster; @@ -24,9 +23,7 @@ async fn management_serves_the_applicable_state_of_the_desired_state() { assert_eq!( agent_desired_state, - AgentDesiredState::from(BalancerDesiredState::unconfigured( - InferenceMode::TextGeneration - )) + AgentDesiredState::from(BalancerDesiredState::default()) ); cluster diff --git a/paddler_tests/tests/openai_chat_completion_is_unavailable_while_the_cluster_serves_another_inference_mode.rs b/paddler_tests/tests/openai_chat_completion_is_unavailable_while_the_cluster_serves_another_inference_mode.rs new file mode 100644 index 000000000..eb096268a --- /dev/null +++ b/paddler_tests/tests/openai_chat_completion_is_unavailable_while_the_cluster_serves_another_inference_mode.rs @@ -0,0 +1,63 @@ +use reqwest::Client; +use reqwest::StatusCode; +use serde_json::Value; +use serde_json::json; + +use paddler_balancer::compatibility::openai_service::openai_api_path::OpenAIApiPath; +use paddler_messaging::inference_mode::InferenceMode; +use paddler_openai_response_format_validator::openai_validator::OpenAIValidator; +use paddler_tests::cluster_without_agents_serving::cluster_without_agents_serving; +use paddler_tests::start_cluster::start_cluster; + +#[tokio::test(flavor = "multi_thread")] +async fn openai_chat_completion_is_unavailable_while_the_cluster_serves_another_inference_mode() { + let cluster = start_cluster(cluster_without_agents_serving(InferenceMode::Embeddings)) + .await + .expect("a balancer serving embeddings must start"); + + let response = Client::new() + .post( + cluster + .balancer + .compat_openai_base_url() + .expect("the cluster serves OpenAI compatibility") + .join(OpenAIApiPath::CHAT_COMPLETIONS) + .expect("the path must join"), + ) + .json(&json!({ + "model": "test-model", + "messages": [{"role": "user", "content": "hi"}] + })) + .send() + .await + .expect("the chat completion request must be answered"); + + assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE); + + let error_body = response + .json::() + .await + .expect("the error body must be JSON"); + + OpenAIValidator::new() + .expect("the OpenAI schema must load") + .validate_error_response(&error_body) + .expect("the error body must follow the OpenAI error schema"); + + assert_eq!( + error_body, + json!({ + "error": { + "message": "The cluster serves Embeddings, not TextGeneration", + "type": "server_error", + "param": null, + "code": null + } + }) + ); + + cluster + .shutdown() + .await + .expect("the cluster must shut down cleanly"); +} diff --git a/paddler_tests/tests/openai_chat_completion_reports_balancer_failures_with_their_http_status.rs b/paddler_tests/tests/openai_chat_completion_reports_balancer_failures_with_their_http_status.rs index f5a9c590e..f1c00c573 100644 --- a/paddler_tests/tests/openai_chat_completion_reports_balancer_failures_with_their_http_status.rs +++ b/paddler_tests/tests/openai_chat_completion_reports_balancer_failures_with_their_http_status.rs @@ -12,20 +12,13 @@ use paddler_openai_response_format_validator::openai_validator::OpenAIValidator; use paddler_test_cluster_harness::cluster::Cluster; use paddler_test_cluster_harness::cluster_params::ClusterParams; use paddler_test_cluster_harness::raw_agent_socket::RawAgentSocket; +use paddler_tests::cluster_without_agents_serving::cluster_without_agents_serving; use paddler_tests::serving_agent_status::serving_agent_status; use paddler_tests::start_cluster::start_cluster; const SHORT_BUFFERED_REQUEST_TIMEOUT: Duration = Duration::from_millis(50); const SHORT_INFERENCE_ITEM_TIMEOUT: Duration = Duration::from_millis(50); -fn agentless_cluster_params() -> ClusterParams { - ClusterParams { - agents: Vec::new(), - wait_for_slots_ready: false, - ..ClusterParams::default() - } -} - #[cfg(test)] async fn chat_completion_failure_status(cluster: &Cluster) -> StatusCode { let response = Client::new() @@ -63,7 +56,7 @@ async fn chat_completion_failure_status(cluster: &Cluster) -> StatusCode { async fn openai_chat_completion_reports_balancer_failures_with_their_http_status() { let waiting_cluster = start_cluster(ClusterParams { buffered_request_timeout: SHORT_BUFFERED_REQUEST_TIMEOUT, - ..agentless_cluster_params() + ..cluster_without_agents_serving(InferenceMode::TextGeneration) }) .await .expect("a balancer without agents must start"); @@ -106,7 +99,7 @@ async fn openai_chat_completion_reports_balancer_failures_with_their_http_status let overflowing_cluster = start_cluster(ClusterParams { max_buffered_requests: 0, - ..agentless_cluster_params() + ..cluster_without_agents_serving(InferenceMode::TextGeneration) }) .await .expect("a balancer without agents must start"); @@ -123,7 +116,7 @@ async fn openai_chat_completion_reports_balancer_failures_with_their_http_status let stalling_cluster = start_cluster(ClusterParams { inference_item_timeout: SHORT_INFERENCE_ITEM_TIMEOUT, - ..agentless_cluster_params() + ..cluster_without_agents_serving(InferenceMode::TextGeneration) }) .await .expect("a balancer without agents must start"); diff --git a/paddler_tests/tests/openai_responses_is_unavailable_while_the_cluster_serves_another_inference_mode.rs b/paddler_tests/tests/openai_responses_is_unavailable_while_the_cluster_serves_another_inference_mode.rs new file mode 100644 index 000000000..ebb5c1e20 --- /dev/null +++ b/paddler_tests/tests/openai_responses_is_unavailable_while_the_cluster_serves_another_inference_mode.rs @@ -0,0 +1,60 @@ +use reqwest::Client; +use reqwest::StatusCode; +use serde_json::Value; +use serde_json::json; + +use paddler_balancer::compatibility::openai_service::openai_api_path::OpenAIApiPath; +use paddler_messaging::inference_mode::InferenceMode; +use paddler_openai_response_format_validator::openai_validator::OpenAIValidator; +use paddler_tests::cluster_without_agents_serving::cluster_without_agents_serving; +use paddler_tests::start_cluster::start_cluster; + +#[tokio::test(flavor = "multi_thread")] +async fn openai_responses_is_unavailable_while_the_cluster_serves_another_inference_mode() { + let cluster = start_cluster(cluster_without_agents_serving(InferenceMode::Embeddings)) + .await + .expect("a balancer serving embeddings must start"); + + let response = Client::new() + .post( + cluster + .balancer + .compat_openai_base_url() + .expect("the cluster serves OpenAI compatibility") + .join(OpenAIApiPath::RESPONSES) + .expect("the path must join"), + ) + .json(&json!({"model": "test-model", "input": "hi"})) + .send() + .await + .expect("the responses request must be answered"); + + assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE); + + let error_body = response + .json::() + .await + .expect("the error body must be JSON"); + + OpenAIValidator::new() + .expect("the OpenAI schema must load") + .validate_error_response(&error_body) + .expect("the error body must follow the OpenAI error schema"); + + assert_eq!( + error_body, + json!({ + "error": { + "message": "The cluster serves Embeddings, not TextGeneration", + "type": "server_error", + "param": null, + "code": null + } + }) + ); + + cluster + .shutdown() + .await + .expect("the cluster must shut down cleanly"); +} diff --git a/paddler_tests/tests/raw_inference_socket_reports_a_socket_closed_before_any_answer.rs b/paddler_tests/tests/raw_inference_socket_reports_a_socket_closed_before_any_answer.rs new file mode 100644 index 000000000..0f6f512f2 --- /dev/null +++ b/paddler_tests/tests/raw_inference_socket_reports_a_socket_closed_before_any_answer.rs @@ -0,0 +1,28 @@ +use paddler_messaging::inference_mode::InferenceMode; +use paddler_test_cluster_harness::cluster_harness_error::ClusterHarnessError; +use paddler_test_cluster_harness::raw_inference_socket::RawInferenceSocket; +use paddler_tests::cluster_without_agents_serving::cluster_without_agents_serving; +use paddler_tests::start_cluster::start_cluster; + +#[tokio::test(flavor = "multi_thread")] +async fn raw_inference_socket_reports_a_socket_closed_before_any_answer() { + let cluster = start_cluster(cluster_without_agents_serving( + InferenceMode::TextGeneration, + )) + .await + .expect("a balancer serving text generation must start"); + let mut raw_inference_socket = + RawInferenceSocket::connect(cluster.balancer.addresses.inference) + .await + .expect("the raw socket must reach the inference socket"); + + cluster + .shutdown() + .await + .expect("the cluster must shut down cleanly"); + + assert!(matches!( + raw_inference_socket.next_answer().await, + Err(ClusterHarnessError::InferenceSocketClosedBeforeAnyAnswer) + )); +} diff --git a/paddler_tests/tests/run_with_test_cluster_exports_the_url_of_every_service.rs b/paddler_tests/tests/run_with_test_cluster_exports_the_url_of_every_service.rs new file mode 100644 index 000000000..44f834697 --- /dev/null +++ b/paddler_tests/tests/run_with_test_cluster_exports_the_url_of_every_service.rs @@ -0,0 +1,22 @@ +#![cfg(feature = "tests_that_use_llms")] + +use std::process::Command; + +#[test] +fn run_with_test_cluster_exports_the_url_of_every_service() { + assert!( + Command::new(env!("CARGO_BIN_EXE_run_with_test_cluster")) + .args([ + "qwen3-0-6b", + "--", + "printenv", + "PADDLER_COMPAT_OPENAI_URL", + "PADDLER_COMPAT_TYPESAFE_URL", + "PADDLER_INFERENCE_URL", + "PADDLER_MANAGEMENT_URL", + ]) + .status() + .expect("the test cluster runner must run") + .success() + ); +} diff --git a/paddler_tests/tests/run_with_test_cluster_exposes_only_the_services_its_cluster_serves.rs b/paddler_tests/tests/run_with_test_cluster_exposes_only_the_services_its_cluster_serves.rs deleted file mode 100644 index 06ae8da80..000000000 --- a/paddler_tests/tests/run_with_test_cluster_exposes_only_the_services_its_cluster_serves.rs +++ /dev/null @@ -1,22 +0,0 @@ -#![cfg(feature = "tests_that_use_llms")] - -use std::process::Command; - -#[test] -fn run_with_test_cluster_exposes_only_the_services_its_cluster_serves() { - assert_eq!( - [ - ["kev-0-8b", "PADDLER_COMPAT_TYPESAFE_URL"], - ["qwen3-0-6b", "PADDLER_COMPAT_OPENAI_URL"], - ["kev-0-8b", "PADDLER_COMPAT_OPENAI_URL"], - ] - .map(|[preset, variable]| { - Command::new(env!("CARGO_BIN_EXE_run_with_test_cluster")) - .args([preset, "--", "printenv", variable]) - .status() - .expect("the test cluster runner must run") - .success() - }), - [true, true, false] - ); -} diff --git a/paddler_tests/tests/typesafe_health_returns_ok.rs b/paddler_tests/tests/typesafe_health_returns_ok.rs index 1547c8872..bf2e943dd 100644 --- a/paddler_tests/tests/typesafe_health_returns_ok.rs +++ b/paddler_tests/tests/typesafe_health_returns_ok.rs @@ -1,12 +1,13 @@ use tokio_util::sync::CancellationToken; use paddler_client::reports_health::ReportsHealth as _; -use paddler_tests::decision_cluster_without_agents_params::decision_cluster_without_agents_params; +use paddler_messaging::inference_mode::InferenceMode; +use paddler_tests::cluster_without_agents_serving::cluster_without_agents_serving; use paddler_tests::start_cluster::start_cluster; #[tokio::test(flavor = "multi_thread")] async fn typesafe_health_returns_ok() { - let cluster = start_cluster(decision_cluster_without_agents_params()) + let cluster = start_cluster(cluster_without_agents_serving(InferenceMode::Decision)) .await .expect("a decision balancer without agents must start"); diff --git a/paddler_tests/tests/typesafe_models_is_unavailable_while_the_cluster_serves_another_inference_mode.rs b/paddler_tests/tests/typesafe_models_is_unavailable_while_the_cluster_serves_another_inference_mode.rs new file mode 100644 index 000000000..6a4177de2 --- /dev/null +++ b/paddler_tests/tests/typesafe_models_is_unavailable_while_the_cluster_serves_another_inference_mode.rs @@ -0,0 +1,29 @@ +use reqwest::StatusCode; +use serde_json::json; + +use paddler_messaging::inference_mode::InferenceMode; +use paddler_tests::cluster_without_agents_serving::cluster_without_agents_serving; +use paddler_tests::start_cluster::start_cluster; + +#[tokio::test(flavor = "multi_thread")] +async fn typesafe_models_is_unavailable_while_the_cluster_serves_another_inference_mode() { + let cluster = start_cluster(cluster_without_agents_serving(InferenceMode::Embeddings)) + .await + .expect("a balancer serving embeddings must start"); + + let response = cluster + .typesafe_models() + .await + .expect("the models request must be answered"); + + assert_eq!(response.status, StatusCode::SERVICE_UNAVAILABLE); + assert_eq!( + response.body, + json!({"detail": "The cluster serves Embeddings, not Decision"}) + ); + + cluster + .shutdown() + .await + .expect("the cluster must shut down cleanly"); +} diff --git a/paddler_tests/tests/typesafe_models_lists_the_model_the_cluster_serves.rs b/paddler_tests/tests/typesafe_models_lists_the_model_the_cluster_serves.rs index a24942a50..3770ac7a4 100644 --- a/paddler_tests/tests/typesafe_models_lists_the_model_the_cluster_serves.rs +++ b/paddler_tests/tests/typesafe_models_lists_the_model_the_cluster_serves.rs @@ -9,7 +9,7 @@ use paddler_messaging::inference_mode::InferenceMode; use paddler_messaging::url_model_reference::UrlModelReference; use paddler_test_cluster_harness::cluster_desired_state::ClusterDesiredState; use paddler_test_cluster_harness::cluster_params::ClusterParams; -use paddler_tests::decision_cluster_without_agents_params::decision_cluster_without_agents_params; +use paddler_tests::cluster_without_agents_serving::cluster_without_agents_serving; use paddler_tests::start_cluster::start_cluster; #[tokio::test(flavor = "multi_thread")] @@ -38,9 +38,10 @@ async fn typesafe_models_lists_the_model_the_cluster_serves() { let cluster = start_cluster(ClusterParams { desired_state: ClusterDesiredState::Apply(Box::new(BalancerDesiredState { model, - ..BalancerDesiredState::unconfigured(InferenceMode::Decision) + inference_mode: InferenceMode::Decision, + ..BalancerDesiredState::default() })), - ..decision_cluster_without_agents_params() + ..cluster_without_agents_serving(InferenceMode::Decision) }) .await .expect("a decision balancer without agents must start"); diff --git a/paddler_tests/tests/typesafe_service_lets_browsers_send_and_read_typesafe_headers.rs b/paddler_tests/tests/typesafe_service_lets_browsers_send_and_read_typesafe_headers.rs index 593980640..2db686a1d 100644 --- a/paddler_tests/tests/typesafe_service_lets_browsers_send_and_read_typesafe_headers.rs +++ b/paddler_tests/tests/typesafe_service_lets_browsers_send_and_read_typesafe_headers.rs @@ -5,8 +5,9 @@ use reqwest::header::HeaderValue; use paddler_balancer::compatibility::typesafe_service::typesafe_api_path::TypeSafeApiPath; use paddler_balancer::compatibility::typesafe_service::typesafe_header::TypeSafeHeader; +use paddler_messaging::inference_mode::InferenceMode; use paddler_test_cluster_harness::cluster_params::ClusterParams; -use paddler_tests::decision_cluster_without_agents_params::decision_cluster_without_agents_params; +use paddler_tests::cluster_without_agents_serving::cluster_without_agents_serving; use paddler_tests::start_cluster::start_cluster; const ALLOWED_ORIGIN: &str = "http://example.com"; @@ -15,7 +16,7 @@ const ALLOWED_ORIGIN: &str = "http://example.com"; async fn typesafe_service_lets_browsers_send_and_read_typesafe_headers() { let cluster = start_cluster(ClusterParams { inference_cors_allowed_hosts: vec![ALLOWED_ORIGIN.to_owned()], - ..decision_cluster_without_agents_params() + ..cluster_without_agents_serving(InferenceMode::Decision) }) .await .expect("a decision balancer without agents must start"); diff --git a/paddler_tests/tests/typesafe_system_one_answers_questions_in_their_order.rs b/paddler_tests/tests/typesafe_system_one_answers_questions_in_their_order.rs index 589249f06..658cc96ee 100644 --- a/paddler_tests/tests/typesafe_system_one_answers_questions_in_their_order.rs +++ b/paddler_tests/tests/typesafe_system_one_answers_questions_in_their_order.rs @@ -11,7 +11,7 @@ use paddler_messaging::jsonrpc::response_envelope::ResponseEnvelope; use paddler_messaging::management_socket::agent::request::Request as AgentJsonRpcRequest; use paddler_messaging::management_socket::agent::response::Response as AgentJsonRpcResponse; use paddler_test_cluster_harness::raw_agent_socket::RawAgentSocket; -use paddler_tests::decision_cluster_without_agents_params::decision_cluster_without_agents_params; +use paddler_tests::cluster_without_agents_serving::cluster_without_agents_serving; use paddler_tests::serving_agent_status::serving_agent_status; use paddler_tests::start_cluster::start_cluster; @@ -26,7 +26,7 @@ fn answer(id: &str, probabilities: Vec) -> AgentJsonRpcResponse { #[tokio::test(flavor = "multi_thread")] async fn typesafe_system_one_answers_questions_in_their_order() { - let cluster = start_cluster(decision_cluster_without_agents_params()) + let cluster = start_cluster(cluster_without_agents_serving(InferenceMode::Decision)) .await .expect("a decision balancer without agents must start"); let mut raw_agent_socket = diff --git a/paddler_tests/tests/typesafe_system_one_is_unavailable_while_the_cluster_serves_another_inference_mode.rs b/paddler_tests/tests/typesafe_system_one_is_unavailable_while_the_cluster_serves_another_inference_mode.rs new file mode 100644 index 000000000..4fe4adf42 --- /dev/null +++ b/paddler_tests/tests/typesafe_system_one_is_unavailable_while_the_cluster_serves_another_inference_mode.rs @@ -0,0 +1,34 @@ +use reqwest::StatusCode; +use serde_json::json; + +use paddler_messaging::inference_mode::InferenceMode; +use paddler_tests::cluster_without_agents_serving::cluster_without_agents_serving; +use paddler_tests::start_cluster::start_cluster; + +#[tokio::test(flavor = "multi_thread")] +async fn typesafe_system_one_is_unavailable_while_the_cluster_serves_another_inference_mode() { + let cluster = start_cluster(cluster_without_agents_serving( + InferenceMode::TextGeneration, + )) + .await + .expect("a balancer serving text generation must start"); + + let response = cluster + .typesafe_system_one( + "text-generation-cluster-request", + &json!({"state": "state", "questions": {"paid": {"type": "noul"}}}), + ) + .await + .expect("the System One request must be answered"); + + assert_eq!(response.status, StatusCode::SERVICE_UNAVAILABLE); + assert_eq!( + response.body, + json!({"detail": "The cluster serves TextGeneration, not Decision"}) + ); + + cluster + .shutdown() + .await + .expect("the cluster must shut down cleanly"); +} diff --git a/paddler_tests/tests/typesafe_system_one_rejects_malformed_requests_as_unprocessable.rs b/paddler_tests/tests/typesafe_system_one_rejects_malformed_requests_as_unprocessable.rs index b835d4ebc..83b2a4bd3 100644 --- a/paddler_tests/tests/typesafe_system_one_rejects_malformed_requests_as_unprocessable.rs +++ b/paddler_tests/tests/typesafe_system_one_rejects_malformed_requests_as_unprocessable.rs @@ -3,12 +3,13 @@ use reqwest::StatusCode; use serde_json::json; use paddler_balancer::compatibility::typesafe_service::typesafe_api_path::TypeSafeApiPath; -use paddler_tests::decision_cluster_without_agents_params::decision_cluster_without_agents_params; +use paddler_messaging::inference_mode::InferenceMode; +use paddler_tests::cluster_without_agents_serving::cluster_without_agents_serving; use paddler_tests::start_cluster::start_cluster; #[tokio::test(flavor = "multi_thread")] async fn typesafe_system_one_rejects_malformed_requests_as_unprocessable() { - let cluster = start_cluster(decision_cluster_without_agents_params()) + let cluster = start_cluster(cluster_without_agents_serving(InferenceMode::Decision)) .await .expect("a decision balancer without agents must start"); diff --git a/paddler_tests/tests/typesafe_system_one_reports_balancer_failures_with_their_http_status.rs b/paddler_tests/tests/typesafe_system_one_reports_balancer_failures_with_their_http_status.rs index 0d9cf0c1f..869702292 100644 --- a/paddler_tests/tests/typesafe_system_one_reports_balancer_failures_with_their_http_status.rs +++ b/paddler_tests/tests/typesafe_system_one_reports_balancer_failures_with_their_http_status.rs @@ -8,7 +8,7 @@ use tokio::join; use paddler_messaging::inference_mode::InferenceMode; use paddler_test_cluster_harness::cluster_params::ClusterParams; use paddler_test_cluster_harness::raw_agent_socket::RawAgentSocket; -use paddler_tests::decision_cluster_without_agents_params::decision_cluster_without_agents_params; +use paddler_tests::cluster_without_agents_serving::cluster_without_agents_serving; use paddler_tests::serving_agent_status::serving_agent_status; use paddler_tests::start_cluster::start_cluster; @@ -22,7 +22,7 @@ fn noul_request() -> Value { async fn typesafe_system_one_reports_balancer_failures_with_their_http_status() { let waiting_cluster = start_cluster(ClusterParams { buffered_request_timeout: SHORT_BUFFERED_REQUEST_TIMEOUT, - ..decision_cluster_without_agents_params() + ..cluster_without_agents_serving(InferenceMode::Decision) }) .await .expect("a decision balancer without agents must start"); @@ -75,7 +75,7 @@ async fn typesafe_system_one_reports_balancer_failures_with_their_http_status() let overflowing_cluster = start_cluster(ClusterParams { max_buffered_requests: 0, - ..decision_cluster_without_agents_params() + ..cluster_without_agents_serving(InferenceMode::Decision) }) .await .expect("a decision balancer without agents must start"); diff --git a/resources/ts/components/ChangeModelForm.tsx b/resources/ts/components/ChangeModelForm.tsx index 89ab7a883..4e1b3328a 100644 --- a/resources/ts/components/ChangeModelForm.tsx +++ b/resources/ts/components/ChangeModelForm.tsx @@ -7,15 +7,15 @@ import React, { } from "react"; import { useLocation } from "wouter"; -import { type AgentDesiredModel } from "@intentee/paddler-client/schemas/AgentDesiredModel"; import { type BalancerDesiredState } from "@intentee/paddler-client/schemas/BalancerDesiredState"; -import { type BalancerInferenceSettings } from "@intentee/paddler-client/schemas/BalancerInferenceSettings"; +import { inferenceModes } from "@intentee/paddler-client/schemas/InferenceMode"; import { BalancerDesiredStateContext } from "../contexts/BalancerDesiredStateContext"; import { PaddlerConfigurationContext } from "../contexts/PaddlerConfigurationContext"; import { useAgentDesiredModelUrl } from "../hooks/useAgentDesiredModelUrl"; import { ChatTemplateBehavior } from "./ChatTemplateBehavior"; import { EmbeddingParametersFields } from "./EmbeddingParametersFields"; import { ModelRuntimeParametersFields } from "./ModelRuntimeParametersFields"; +import { ParameterSelect } from "./ParameterSelect"; import { TextGenerationSettingsFields } from "./TextGenerationSettingsFields"; import { @@ -33,41 +33,6 @@ import { changeModelForm__submitButton, } from "./ChangeModelForm.module.css"; -function withMultimodalProjection( - inferenceSettings: BalancerInferenceSettings, - projection: AgentDesiredModel, -): BalancerInferenceSettings { - if (!("TextGeneration" in inferenceSettings)) { - return inferenceSettings; - } - - return { - TextGeneration: { - ...inferenceSettings.TextGeneration, - multimodal: { - ...inferenceSettings.TextGeneration.multimodal, - projection, - }, - }, - }; -} - -function withPointerHead( - inferenceSettings: BalancerInferenceSettings, - pointerHead: AgentDesiredModel, -): BalancerInferenceSettings { - if (!("Decision" in inferenceSettings)) { - return inferenceSettings; - } - - return { - Decision: { - ...inferenceSettings.Decision, - pointer_head: pointerHead, - }, - }; -} - export function ChangeModelForm({ defaultBaseModelUri, defaultMultimodalProjectionUri, @@ -78,12 +43,12 @@ export function ChangeModelForm({ defaultPointerHeadUri: null | string; }) { const [, navigate] = useLocation(); - const { balancerDesiredState: editedDesiredState } = useContext( - BalancerDesiredStateContext, - ); - const { inferenceMode, managementAddr } = useContext( - PaddlerConfigurationContext, - ); + const { + balancerDesiredState: editedDesiredState, + setBalancerDesiredState: setEditedDesiredState, + } = useContext(BalancerDesiredStateContext); + const { inference_mode: inferenceMode } = editedDesiredState; + const { managementAddr } = useContext(PaddlerConfigurationContext); const { agentDesiredModelState: baseModelAgentDesiredModelState, modelUri: baseModelUri, @@ -139,14 +104,19 @@ export function ChangeModelForm({ const desiredState: BalancerDesiredState = Object.freeze({ ...editedDesiredState, - inference_settings: withPointerHead( - withMultimodalProjection( - editedDesiredState.inference_settings, - multimodalProjecttionAgentDesiredModelState.agentDesiredModel, - ), - pointerHeadAgentDesiredModelState.agentDesiredModel, - ), + decision: { + ...editedDesiredState.decision, + pointer_head: pointerHeadAgentDesiredModelState.agentDesiredModel, + }, model: baseModelAgentDesiredModelState.agentDesiredModel, + text_generation: { + ...editedDesiredState.text_generation, + multimodal: { + ...editedDesiredState.text_generation.multimodal, + projection: + multimodalProjecttionAgentDesiredModelState.agentDesiredModel, + }, + }, }); return desiredState; @@ -238,6 +208,21 @@ export function ChangeModelForm({
    +
    + Inference Mode + +