From 371bd55eea6047f347202643b7b069eb0962a7d3 Mon Sep 17 00:00:00 2001 From: rgrant Date: Wed, 16 Sep 2026 20:26:33 +0000 Subject: [PATCH 1/2] feat(config, llm): Add a `vllm` provider For vLLM compatibility, the openai provider has the Bearer token auth, but it speaks a Responses dialect that vLLM does not serve. The llamacpp provider speaks the Chat Completions dialect that vLLM serves, but it sends no auth and it strips the vendor prefix from the model id. We need a new provider for vLLM. Users can now point `jp` at a self-hosted vLLM server by setting a model's provider to `vllm`. vLLM speaks the same OpenAI-compatible `/v1/chat/completions` dialect as llama.cpp, so it supports streaming chat, tool calls, structured output, and thinking control the same way. Model listings come from `GET /v1/models`, and the reported `max_model_len` becomes the model's context window; the model name keeps its full id (e.g. `Qwen/Qwen3-8B`) since vLLM only accepts that form in requests. `providers.llm.vllm` configures the new provider, with `api_key_env` defaulting to `VLLM_API_KEY` and `base_url` defaulting to `http://127.0.0.1:8000`: ```toml [providers.llm.vllm] api_key_env = "VLLM_API_KEY" base_url = "http://127.0.0.1:8000" ``` To support this without duplicating llama.cpp's SSE parsing and event conversion, the shared OpenAI-compatible message/tool conversion and stream assembly logic moves from `provider/llamacpp.rs` into `provider/openai_compat.rs`, where both providers now call it. This is behavior-preserving for llama.cpp. Closes: #0khs4b1 Signed-off-by: rgrant --- crates/jp_config/src/model/id.rs | 4 + crates/jp_config/src/model/id_tests.rs | 1 + crates/jp_config/src/providers/llm.rs | 11 + crates/jp_config/src/providers/llm/vllm.rs | 77 ++++ .../jp_config__tests__app_config_fields.snap | 2 + ...onfig__tests__app_config_schema_shape.snap | 19 +- ...ig__tests__partial_app_config_default.snap | 4 + ...ts__partial_app_config_default_values.snap | 8 + ...s__partial_app_config_empty_serialize.snap | 4 + crates/jp_llm/src/provider.rs | 4 + crates/jp_llm/src/provider/llamacpp.rs | 414 +---------------- crates/jp_llm/src/provider/llamacpp_tests.rs | 332 +------------- crates/jp_llm/src/provider/openai_compat.rs | 419 +++++++++++++++++- .../src/provider/openai_compat_tests.rs | 311 +++++++++++++ crates/jp_llm/src/provider/vllm.rs | 315 +++++++++++++ crates/jp_llm/src/provider/vllm_tests.rs | 205 +++++++++ crates/jp_llm/src/test.rs | 19 + ...ompletion_stream__conversation_stream.snap | 4 + ...tool_soft_forces__conversation_stream.snap | 4 + ...image_attachment__conversation_stream.snap | 4 + ...urn_conversation__conversation_stream.snap | 4 + ...daptive_thinking__conversation_stream.snap | 4 + ...s_4_6_max_effort__conversation_stream.snap | 4 + ...edacted_thinking__conversation_stream.snap | 4 + ...request_chaining__conversation_stream.snap | 4 + ...tructured_output__conversation_stream.snap | 4 + ...t_tool_call_auto__conversation_stream.snap | 4 + ...ol_call_function__conversation_stream.snap | 4 + ...l_call_reasoning__conversation_stream.snap | 4 + ...red_no_reasoning__conversation_stream.snap | 4 + ...quired_reasoning__conversation_stream.snap | 4 + ...tool_call_stream__conversation_stream.snap | 4 + ...ompletion_stream__conversation_stream.snap | 4 + ...urn_conversation__conversation_stream.snap | 4 + ...tructured_output__conversation_stream.snap | 4 + ...t_tool_call_auto__conversation_stream.snap | 4 + ...ol_call_function__conversation_stream.snap | 4 + ...l_call_reasoning__conversation_stream.snap | 4 + ...red_no_reasoning__conversation_stream.snap | 4 + ...quired_reasoning__conversation_stream.snap | 4 + ...tool_call_stream__conversation_stream.snap | 4 + ...uto_omits_effort__conversation_stream.snap | 4 + ...l_off_sends_none__conversation_stream.snap | 4 + ...ompletion_stream__conversation_stream.snap | 4 + ...mini_3_reasoning__conversation_stream.snap | 4 + ...image_attachment__conversation_stream.snap | 4 + ...urn_conversation__conversation_stream.snap | 4 + ...tructured_output__conversation_stream.snap | 4 + ...t_tool_call_auto__conversation_stream.snap | 4 + ...ol_call_function__conversation_stream.snap | 4 + ...l_call_reasoning__conversation_stream.snap | 4 + ...red_no_reasoning__conversation_stream.snap | 4 + ...quired_reasoning__conversation_stream.snap | 4 + ...tool_call_stream__conversation_stream.snap | 4 + ...d_thinking_level__conversation_stream.snap | 4 + ...ompletion_stream__conversation_stream.snap | 4 + ...image_attachment__conversation_stream.snap | 4 + ...urn_conversation__conversation_stream.snap | 4 + ...tructured_output__conversation_stream.snap | 4 + ...t_tool_call_auto__conversation_stream.snap | 4 + ...ol_call_function__conversation_stream.snap | 4 + ...l_call_reasoning__conversation_stream.snap | 4 + ...red_no_reasoning__conversation_stream.snap | 4 + ...quired_reasoning__conversation_stream.snap | 4 + ...tool_call_stream__conversation_stream.snap | 4 + ...ompletion_stream__conversation_stream.snap | 4 + ...image_attachment__conversation_stream.snap | 4 + ...urn_conversation__conversation_stream.snap | 4 + ...tructured_output__conversation_stream.snap | 4 + ...t_tool_call_auto__conversation_stream.snap | 4 + ...ol_call_function__conversation_stream.snap | 4 + ...l_call_reasoning__conversation_stream.snap | 4 + ...red_no_reasoning__conversation_stream.snap | 4 + ...quired_reasoning__conversation_stream.snap | 4 + ...tool_call_stream__conversation_stream.snap | 4 + ...ompletion_stream__conversation_stream.snap | 4 + ..._explicit_optout__conversation_stream.snap | 4 + ...explicit_caching__conversation_stream.snap | 4 + ...read_after_write__conversation_stream.snap | 4 + ...image_attachment__conversation_stream.snap | 4 + ...urn_conversation__conversation_stream.snap | 4 + ...nsupported_model__conversation_stream.snap | 4 + ...tructured_output__conversation_stream.snap | 4 + ...t_tool_call_auto__conversation_stream.snap | 4 + ...ol_call_function__conversation_stream.snap | 4 + ...l_call_reasoning__conversation_stream.snap | 4 + ...red_no_reasoning__conversation_stream.snap | 4 + ...quired_reasoning__conversation_stream.snap | 4 + ...tool_call_stream__conversation_stream.snap | 4 + ...r_event_metadata__conversation_stream.snap | 4 + ...r_event_metadata__conversation_stream.snap | 4 + ...r_event_metadata__conversation_stream.snap | 4 + ..._tool_round_trip__conversation_stream.snap | 4 + ...ompletion_stream__conversation_stream.snap | 4 + ...image_attachment__conversation_stream.snap | 4 + ...urn_conversation__conversation_stream.snap | 4 + ...tructured_output__conversation_stream.snap | 4 + ...t_tool_call_auto__conversation_stream.snap | 4 + ...ol_call_function__conversation_stream.snap | 4 + ...l_call_reasoning__conversation_stream.snap | 4 + ...red_no_reasoning__conversation_stream.snap | 4 + ...quired_reasoning__conversation_stream.snap | 4 + ...tool_call_stream__conversation_stream.snap | 4 + ...r_event_metadata__conversation_stream.snap | 4 + docs/features/tools.md | 2 +- docs/ticket/0khs4b1-add-a-vllm-provider.md | 76 ++++ 106 files changed, 1830 insertions(+), 745 deletions(-) create mode 100644 crates/jp_config/src/providers/llm/vllm.rs create mode 100644 crates/jp_llm/src/provider/vllm.rs create mode 100644 crates/jp_llm/src/provider/vllm_tests.rs create mode 100644 docs/ticket/0khs4b1-add-a-vllm-provider.md diff --git a/crates/jp_config/src/model/id.rs b/crates/jp_config/src/model/id.rs index e19500eb6..0171cefaa 100644 --- a/crates/jp_config/src/model/id.rs +++ b/crates/jp_config/src/model/id.rs @@ -595,6 +595,9 @@ pub enum ProviderId { /// Openrouter provider. /// See: . Openrouter, + /// vLLM provider: a self-hosted vLLM server with an OpenAI-compatible API. + /// See: . + Vllm, /// xAI provider. /// See: . /// UNIMPLEMENTED. @@ -619,6 +622,7 @@ impl ProviderId { Self::Ollama => "ollama", Self::Openai => "openai", Self::Openrouter => "openrouter", + Self::Vllm => "vllm", Self::Xai => "xai", Self::Test => "test", diff --git a/crates/jp_config/src/model/id_tests.rs b/crates/jp_config/src/model/id_tests.rs index c91aeac00..bba3c1ba9 100644 --- a/crates/jp_config/src/model/id_tests.rs +++ b/crates/jp_config/src/model/id_tests.rs @@ -42,6 +42,7 @@ fn the_variant_list_leaves_out_the_test_provider() { "ollama", "openai", "openrouter", + "vllm", "xai" ]); } diff --git a/crates/jp_config/src/providers/llm.rs b/crates/jp_config/src/providers/llm.rs index 6a834f760..ef92d4fc3 100644 --- a/crates/jp_config/src/providers/llm.rs +++ b/crates/jp_config/src/providers/llm.rs @@ -8,6 +8,7 @@ pub mod llamacpp; pub mod ollama; pub mod openai; pub mod openrouter; +pub mod vllm; use indexmap::IndexMap; use schematic::{Config, ConfigError}; @@ -27,6 +28,7 @@ use crate::{ ollama::{OllamaConfig, PartialOllamaConfig}, openai::{OpenaiConfig, PartialOpenaiConfig}, openrouter::{OpenrouterConfig, PartialOpenrouterConfig}, + vllm::{PartialVllmConfig, VllmConfig}, }, util::merge_nested_indexmap, validate::Validator, @@ -83,6 +85,10 @@ pub struct LlmProviderConfig { /// Openrouter API configuration. #[setting(nested)] pub openrouter: OpenrouterConfig, + + /// vLLM API configuration. + #[setting(nested)] + pub vllm: VllmConfig, } impl Validator for LlmProviderConfig { @@ -104,6 +110,7 @@ impl AssignKeyValue for PartialLlmProviderConfig { _ if kv.p("ollama") => self.ollama.assign(kv)?, _ if kv.p("openai") => self.openai.assign(kv)?, _ if kv.p("openrouter") => self.openrouter.assign(kv)?, + _ if kv.p("vllm") => self.vllm.assign(kv)?, _ => return missing_key(&kv), } @@ -126,6 +133,7 @@ impl PartialConfigDelta for PartialLlmProviderConfig { ollama: self.ollama.delta(next.ollama), openai: self.openai.delta(next.openai), openrouter: self.openrouter.delta(next.openrouter), + vllm: self.vllm.delta(next.vllm), } } @@ -148,6 +156,7 @@ impl PartialConfigDelta for PartialLlmProviderConfig { &path(prefix, "openrouter"), unsets, ), + vllm: self.vllm.delta(next.vllm), } } } @@ -164,6 +173,7 @@ impl FillDefaults for PartialLlmProviderConfig { ollama: self.ollama.fill_from(defaults.ollama), openai: self.openai.fill_from(defaults.openai), openrouter: self.openrouter.fill_from(defaults.openrouter), + vllm: self.vllm.fill_from(defaults.vllm), } } } @@ -184,6 +194,7 @@ impl ToPartial for LlmProviderConfig { ollama: self.ollama.to_partial(), openai: self.openai.to_partial(), openrouter: self.openrouter.to_partial(), + vllm: self.vllm.to_partial(), } } } diff --git a/crates/jp_config/src/providers/llm/vllm.rs b/crates/jp_config/src/providers/llm/vllm.rs new file mode 100644 index 000000000..ea307f815 --- /dev/null +++ b/crates/jp_config/src/providers/llm/vllm.rs @@ -0,0 +1,77 @@ +//! vLLM API configuration. +//! +//! A vLLM server speaks the OpenAI-compatible `/v1/chat/completions` dialect +//! and checks the Bearer token given to it with `--api-key`. +//! +//! ```toml +//! [providers.llm.vllm] +//! api_key_env = "VLLM_API_KEY" +//! base_url = "http://127.0.0.1:8000" +//! ``` + +use schematic::Config; + +use crate::{ + assignment::{AssignKeyValue, AssignResult, KvAssignment, missing_key}, + delta::{PartialConfigDelta, delta_opt}, + fill::FillDefaults, + partial::{ToPartial, partial_opt}, +}; + +/// vLLM API configuration. +#[derive(Debug, Clone, PartialEq, Config)] +#[config(rename_all = "snake_case")] +pub struct VllmConfig { + /// Environment variable that contains the API key. + #[setting(default = "VLLM_API_KEY")] + pub api_key_env: String, + + /// The base URL to use for API requests. + /// + /// The default is `http://127.0.0.1:8000`, which is the default URL for a + /// vLLM server. + #[setting(default = "http://127.0.0.1:8000")] + pub base_url: String, +} + +impl AssignKeyValue for PartialVllmConfig { + fn assign(&mut self, kv: KvAssignment) -> AssignResult { + match kv.key_string().as_str() { + "" => kv.try_merge_object(self)?, + "api_key_env" => self.api_key_env = kv.try_some_string()?, + "base_url" => self.base_url = kv.try_some_string()?, + _ => return missing_key(&kv), + } + + Ok(()) + } +} + +impl PartialConfigDelta for PartialVllmConfig { + fn delta(&self, next: Self) -> Self { + Self { + api_key_env: delta_opt(self.api_key_env.as_ref(), next.api_key_env), + base_url: delta_opt(self.base_url.as_ref(), next.base_url), + } + } +} + +impl FillDefaults for PartialVllmConfig { + fn fill_from(self, defaults: Self) -> Self { + Self { + api_key_env: self.api_key_env.or(defaults.api_key_env), + base_url: self.base_url.or(defaults.base_url), + } + } +} + +impl ToPartial for VllmConfig { + fn to_partial(&self) -> Self::Partial { + let defaults = Self::Partial::default(); + + Self::Partial { + api_key_env: partial_opt(&self.api_key_env, defaults.api_key_env), + base_url: partial_opt(&self.base_url, defaults.base_url), + } + } +} diff --git a/crates/jp_config/src/snapshots/jp_config__tests__app_config_fields.snap b/crates/jp_config/src/snapshots/jp_config__tests__app_config_fields.snap index fd285dbab..213438976 100644 --- a/crates/jp_config/src/snapshots/jp_config__tests__app_config_fields.snap +++ b/crates/jp_config/src/snapshots/jp_config__tests__app_config_fields.snap @@ -54,6 +54,8 @@ expression: "AppConfig::fields()" "style.code.line_numbers", "providers.mcp", "providers.llm.aliases", + "providers.llm.vllm.api_key_env", + "providers.llm.vllm.base_url", "providers.llm.openrouter.api_key_env", "providers.llm.openrouter.app_name", "providers.llm.openrouter.app_referrer", diff --git a/crates/jp_config/src/snapshots/jp_config__tests__app_config_schema_shape.snap b/crates/jp_config/src/snapshots/jp_config__tests__app_config_schema_shape.snap index 7462967b0..8dffe8d0f 100644 --- a/crates/jp_config/src/snapshots/jp_config__tests__app_config_schema_shape.snap +++ b/crates/jp_config/src/snapshots/jp_config__tests__app_config_schema_shape.snap @@ -38,7 +38,7 @@ assistant: AssistantConfig id: ModelIdOrAliasConfig |: ModelIdConfig name: string - provider: "anthropic" | "cerebras" | "deepseek" | "google" | "llamacpp" | "ollama" | "openai" | "openrouter" | "xai" + provider: "anthropic" | "cerebras" | "deepseek" | "google" | "llamacpp" | "ollama" | "openai" | "openrouter" | "vllm" | "xai" |: string parameters: ParametersConfig max_tokens: int | null @@ -136,7 +136,7 @@ conversation: ConversationConfig id: ModelIdOrAliasConfig |: ModelIdConfig name: string - provider: "anthropic" | "cerebras" | "deepseek" | "google" | "llamacpp" | "ollama" | "openai" | "openrouter" | "xai" + provider: "anthropic" | "cerebras" | "deepseek" | "google" | "llamacpp" | "ollama" | "openai" | "openrouter" | "vllm" | "xai" |: string parameters: ParametersConfig max_tokens: int | null @@ -189,7 +189,7 @@ conversation: ConversationConfig id: ModelIdOrAliasConfig |: ModelIdConfig name: string - provider: "anthropic" | "cerebras" | "deepseek" | "google" | "llamacpp" | "ollama" | "openai" | "openrouter" | "xai" + provider: "anthropic" | "cerebras" | "deepseek" | "google" | "llamacpp" | "ollama" | "openai" | "openrouter" | "vllm" | "xai" |: string parameters: ParametersConfig max_tokens: int | null @@ -256,7 +256,7 @@ conversation: ConversationConfig id: ModelIdOrAliasConfig |: ModelIdConfig name: string - provider: "anthropic" | "cerebras" | "deepseek" | "google" | "llamacpp" | "ollama" | "openai" | "openrouter" | "xai" + provider: "anthropic" | "cerebras" | "deepseek" | "google" | "llamacpp" | "ollama" | "openai" | "openrouter" | "vllm" | "xai" |: string parameters: ParametersConfig max_tokens: int | null @@ -363,7 +363,7 @@ conversation: ConversationConfig id: ModelIdOrAliasConfig |: ModelIdConfig name: string - provider: "anthropic" | "cerebras" | "deepseek" | "google" | "llamacpp" | "ollama" | "openai" | "openrouter" | "xai" + provider: "anthropic" | "cerebras" | "deepseek" | "google" | "llamacpp" | "ollama" | "openai" | "openrouter" | "vllm" | "xai" |: string parameters: ParametersConfig max_tokens: int | null @@ -584,7 +584,7 @@ conversation: ConversationConfig |: PartialModelIdOrAliasConfig |: PartialModelIdConfig name?: string | null - provider?: "anthropic" | "cerebras" | "deepseek" | "google" | "llamacpp" | "ollama" | "openai" | "openrouter" | "xai" | null + provider?: "anthropic" | "cerebras" | "deepseek" | "google" | "llamacpp" | "ollama" | "openai" | "openrouter" | "vllm" | "xai" | null |: string |: null parameters?: @@ -723,7 +723,7 @@ providers: ProviderConfig *: ModelIdOrAliasConfig |: ModelIdConfig name: string - provider: "anthropic" | "cerebras" | "deepseek" | "google" | "llamacpp" | "ollama" | "openai" | "openrouter" | "xai" + provider: "anthropic" | "cerebras" | "deepseek" | "google" | "llamacpp" | "ollama" | "openai" | "openrouter" | "vllm" | "xai" |: string anthropic: AnthropicConfig api_key_env?: string @@ -753,6 +753,9 @@ providers: ProviderConfig app_name?: string app_referrer: string | null base_url?: string + vllm: VllmConfig + api_key_env?: string + base_url?: string mcp: *: McpProviderConfig |: StdioConfig @@ -800,7 +803,7 @@ style: StyleConfig id: ModelIdOrAliasConfig |: ModelIdConfig name: string - provider: "anthropic" | "cerebras" | "deepseek" | "google" | "llamacpp" | "ollama" | "openai" | "openrouter" | "xai" + provider: "anthropic" | "cerebras" | "deepseek" | "google" | "llamacpp" | "ollama" | "openai" | "openrouter" | "vllm" | "xai" |: string parameters: ParametersConfig max_tokens: int | null diff --git a/crates/jp_config/src/snapshots/jp_config__tests__partial_app_config_default.snap b/crates/jp_config/src/snapshots/jp_config__tests__partial_app_config_default.snap index f4e8b5bb6..3e65999bf 100644 --- a/crates/jp_config/src/snapshots/jp_config__tests__partial_app_config_default.snap +++ b/crates/jp_config/src/snapshots/jp_config__tests__partial_app_config_default.snap @@ -251,6 +251,10 @@ PartialAppConfig { app_referrer: None, base_url: None, }, + vllm: PartialVllmConfig { + api_key_env: None, + base_url: None, + }, }, mcp: {}, }, diff --git a/crates/jp_config/src/snapshots/jp_config__tests__partial_app_config_default_values.snap b/crates/jp_config/src/snapshots/jp_config__tests__partial_app_config_default_values.snap index b94f00f54..bb828576b 100644 --- a/crates/jp_config/src/snapshots/jp_config__tests__partial_app_config_default_values.snap +++ b/crates/jp_config/src/snapshots/jp_config__tests__partial_app_config_default_values.snap @@ -517,6 +517,14 @@ Ok( "https://openrouter.ai", ), }, + vllm: PartialVllmConfig { + api_key_env: Some( + "VLLM_API_KEY", + ), + base_url: Some( + "http://127.0.0.1:8000", + ), + }, }, mcp: {}, }, diff --git a/crates/jp_config/src/snapshots/jp_config__tests__partial_app_config_empty_serialize.snap b/crates/jp_config/src/snapshots/jp_config__tests__partial_app_config_empty_serialize.snap index fe52a9463..211e47ef1 100644 --- a/crates/jp_config/src/snapshots/jp_config__tests__partial_app_config_empty_serialize.snap +++ b/crates/jp_config/src/snapshots/jp_config__tests__partial_app_config_empty_serialize.snap @@ -251,6 +251,10 @@ PartialAppConfig { app_referrer: None, base_url: None, }, + vllm: PartialVllmConfig { + api_key_env: None, + base_url: None, + }, }, mcp: {}, }, diff --git a/crates/jp_llm/src/provider.rs b/crates/jp_llm/src/provider.rs index caff8699f..9e51675b3 100644 --- a/crates/jp_llm/src/provider.rs +++ b/crates/jp_llm/src/provider.rs @@ -9,6 +9,7 @@ pub mod ollama; pub mod openai; pub(crate) mod openai_compat; pub mod openrouter; +pub mod vllm; use std::sync::atomic::{AtomicU64, Ordering}; @@ -24,6 +25,7 @@ use llamacpp::Llamacpp; use ollama::Ollama; use openai::Openai; use openrouter::Openrouter; +use vllm::Vllm; use crate::{ error::Result, model::ModelDetails, provider::mock::MockProvider, query::ChatQuery, @@ -56,6 +58,7 @@ pub fn get_provider(id: ProviderId, config: &LlmProviderConfig) -> Result Box::new(Ollama::try_from(&config.ollama)?), ProviderId::Openai => Box::new(Openai::try_from(&config.openai)?), ProviderId::Openrouter => Box::new(Openrouter::try_from(&config.openrouter)?), + ProviderId::Vllm => Box::new(Vllm::try_from(&config.vllm)?), ProviderId::Deepseek => todo!(), ProviderId::Xai => todo!(), @@ -113,6 +116,7 @@ pub(crate) fn build_request_value( ProviderId::Openrouter => { Openrouter::try_from(&config.openrouter)?.request_value(model, query) } + ProviderId::Vllm => Vllm::try_from(&config.vllm)?.request_value(model, query), ProviderId::Test | ProviderId::Deepseek | ProviderId::Xai => { unreachable!("{id:?} is not part of the request snapshot suite") } diff --git a/crates/jp_llm/src/provider/llamacpp.rs b/crates/jp_llm/src/provider/llamacpp.rs index db10c0545..5e8f17615 100644 --- a/crates/jp_llm/src/provider/llamacpp.rs +++ b/crates/jp_llm/src/provider/llamacpp.rs @@ -1,40 +1,29 @@ -use std::{mem, time::Duration}; +use std::time::Duration; use async_trait::async_trait; use base64::Engine as _; -use futures::{Stream, StreamExt as _, future, stream}; use jp_attachment::AttachmentContent; use jp_config::{ - assistant::tool_choice::ToolChoice, model::{ id::{ModelIdConfig, Name, ProviderId}, parameters::ReasoningConfig, }, providers::llm::llamacpp::LlamacppConfig, }; -use jp_conversation::{ - ConversationStream, - event::{ChatResponse, EventKind, ToolCallResponse}, - thread::text_attachments_to_xml, -}; -use reqwest_eventsource::{Event as SseEvent, EventSource, retry::Never}; +use jp_conversation::thread::text_attachments_to_xml; +use reqwest_eventsource::{EventSource, retry::Never}; use serde::Deserialize; use serde_json::{Value, json}; use tracing::{debug, trace, warn}; use super::{ EventStream, ModelDetails, - openai::parameters_with_strict_mode, - openai_compat::{merge_consecutive_assistant_messages, parse_chunk}, -}; -use crate::{ - error::{Error, StreamError}, - event::{Event, FinishReason}, - provider::Provider, - query::ChatQuery, - stream::{aggregator::reasoning::ReasoningExtractor, with_tool_call_keepalive}, - tool::ToolDefinition, + openai_compat::{ + assemble_event_stream, convert_events, convert_tool_choice, convert_tools, + to_system_messages, + }, }; +use crate::{error::Error, provider::Provider, query::ChatQuery, stream::with_tool_call_keepalive}; static PROVIDER: ProviderId = ProviderId::Llamacpp; @@ -114,306 +103,12 @@ impl Provider for Llamacpp { es.set_retry_policy(Box::new(Never)); Ok(with_tool_call_keepalive( - assemble_event_stream(es, is_structured), + assemble_event_stream(es, "llamacpp", is_structured), TOOL_CALL_KEEPALIVE_INTERVAL, )) } } -/// Assemble the provider-agnostic event stream from a raw SSE event source. -fn assemble_event_stream(events: S, is_structured: bool) -> EventStream -where - S: Stream> + Send + 'static, -{ - let mut state = StreamState { - extractor: ReasoningExtractor::default(), - tool_call_indices: Vec::new(), - reasoning_flushed: false, - message_flushed: false, - finished: false, - finish_reason: None, - is_structured, - }; - - let mut seen_error = false; - events - .take_while(move |event| { - // Include the first error before stopping: it must reach the - // handler below to be surfaced (or dropped once finished), and - // stopping prevents the EventSource from reconnecting after a - // terminal error. - let keep = !seen_error; - if event.is_err() { - seen_error = true; - } - future::ready(keep) - }) - .then(move |event| { - let result = handle_sse_event_sync(event, &mut state); - async move { - match result { - Ok(v) => stream::iter(v).boxed(), - Err(e) => { - stream::iter(vec![Err(StreamError::from_eventsource(e).await)]).boxed() - } - } - } - }) - .flatten() - .boxed() -} - -/// Mutable state carried across SSE events in a single stream. -struct StreamState { - extractor: ReasoningExtractor, - /// Tracks which tool call indices have been seen, so we can flush them on - /// finish. - tool_call_indices: Vec, - reasoning_flushed: bool, - /// Tracks whether `Event::flush(1)` (the message/structured index) has - /// already been emitted in this stream. - /// Without this gate, the `finish_reason` chunk and the `[DONE]` sentinel - /// both emit it, producing a spurious second flush that downstream - /// consumers can misinterpret as a re-dispatch signal. - message_flushed: bool, - /// Whether the terminal `Finished` event has been emitted. - /// Once set, a subsequent stream error is the benign connection close that - /// follows `[DONE]` and is dropped rather than surfaced to the retry layer. - finished: bool, - /// Captured from `finish_reason` in the last choice delta. - /// Emitted as `Event::Finished` when the `[DONE]` sentinel arrives. - finish_reason: Option, - is_structured: bool, -} - -type SseResult = std::result::Result>, reqwest_eventsource::Error>; - -/// Process a single SSE event into zero or more provider-agnostic events. -#[expect(clippy::too_many_lines)] -fn handle_sse_event_sync( - event: Result, - state: &mut StreamState, -) -> SseResult { - match event { - Ok(SseEvent::Open) => Ok(vec![]), - Ok(SseEvent::Message(msg)) => { - trace!(event = %msg.data, "Received event from Llamacpp API."); - - if msg.data == "[DONE]" { - // Finalize the reasoning extractor on stream end. - state.extractor.finalize(); - let mut events: Vec> = - drain_extractor(&mut state.extractor, state.is_structured) - .into_iter() - .map(Ok) - .collect(); - - // Flush reasoning if we never did. - if !state.reasoning_flushed { - events.push(Ok(Event::flush(0))); - state.reasoning_flushed = true; - } - - // Flush message content if we never did. - if !state.message_flushed { - events.push(Ok(Event::flush(1))); - state.message_flushed = true; - } - - // Drain any tool call indices that weren't flushed via - // `finish_reason`. In well-behaved streams this is empty — - // the safety net guards against a missing `finish_reason` - // chunk that would otherwise orphan the tool call buffer. - for index in state.tool_call_indices.drain(..) { - events.push(Ok(Event::flush(index))); - } - - events.push(Ok(Event::Finished( - state - .finish_reason - .take() - .unwrap_or(FinishReason::Completed), - ))); - state.finished = true; - return Ok(events); - } - - let Some(chunk) = parse_chunk(&msg.data, "llamacpp") else { - return Ok(vec![]); - }; - - let mut events = Vec::new(); - - for choice in &chunk.choices { - let delta = &choice.delta; - - // Reasoning via `reasoning_content` (deepseek / deepseek-legacy formats) - if let Some(reasoning) = &delta.reasoning_content - && !reasoning.is_empty() - { - events.push(Ok(Event::reasoning(0, reasoning.clone()))); - } - - // Content - // - // If reasoning_content was present, the server already - // separated reasoning from content (deepseek / - // deepseek-legacy). Otherwise, content may contain tags - // (none format) and needs the extractor. - if let Some(content) = &delta.content - && !content.is_empty() - { - // Server separated reasoning; content is pure text. - if delta.reasoning_content.is_some() { - flush_reasoning_if_needed(&mut events, &mut state.reasoning_flushed); - - if state.is_structured { - events.push(Ok(Event::structured(1, content.clone()))); - } else { - events.push(Ok(Event::message(1, content.clone()))); - } - } else { - // Might contain tags — feed through extractor. - state.extractor.handle(content); - events.extend( - drain_extractor(&mut state.extractor, state.is_structured) - .into_iter() - .map(Ok), - ); - } - } - - // Tool calls - if delta.tool_calls.is_some() { - // A tool call terminates this message's content. Release - // any extractor-held tail now, before the tool-call parts - // are emitted: downstream drains the in-progress markdown - // paragraph at the tool-call boundary, so a tail released - // afterwards would land in a fresh paragraph and render as - // a mid-word blank-line split. - state.extractor.finalize(); - events.extend( - drain_extractor(&mut state.extractor, state.is_structured) - .into_iter() - .map(Ok), - ); - } - - if let Some(tool_calls) = &delta.tool_calls { - flush_reasoning_if_needed(&mut events, &mut state.reasoning_flushed); - - for tc in tool_calls { - let index = tc.index as usize + 2; - - if !state.tool_call_indices.contains(&index) { - state.tool_call_indices.push(index); - } - - let id = tc.id.clone().unwrap_or_default(); - let name = tc - .function - .as_ref() - .and_then(|f| f.name.clone()) - .unwrap_or_default(); - if !id.is_empty() || !name.is_empty() { - events.push(Ok(Event::tool_call_start(index, id, name))); - } - - if let Some(args) = - tc.function.as_ref().and_then(|f| f.arguments.as_deref()) - { - events.push(Ok(Event::tool_call_args(index, args))); - } - } - } - - // Finish reason - if let Some(reason) = &choice.finish_reason { - state.extractor.finalize(); - events.extend( - drain_extractor(&mut state.extractor, state.is_structured) - .into_iter() - .map(Ok), - ); - - // Flush reasoning and message content before tool calls - // so they appear earlier in the conversation history. - if !state.reasoning_flushed { - events.push(Ok(Event::flush(0))); - state.reasoning_flushed = true; - } - if !state.message_flushed { - events.push(Ok(Event::flush(1))); - state.message_flushed = true; - } - - if matches!(reason.as_str(), "tool_calls" | "stop") { - for index in state.tool_call_indices.drain(..) { - events.push(Ok(Event::flush(index))); - } - } - - // Per the OpenAI spec. - match reason.as_str() { - "length" => { - // Active tool-call blocks are structurally - // incomplete when the model hits the token - // limit. Drop them here so the `[DONE]` safety - // net does not commit truncated arguments — - // mirrors `EventBuilder::drain` and the Google - // provider's MaxTokens behaviour. - state.tool_call_indices.clear(); - state.finish_reason = Some(FinishReason::MaxTokens); - } - "stop" => state.finish_reason = Some(FinishReason::Completed), - _ => {} - } - } - } - - Ok(events) - } - Err(e) => { - // A stream error after `Finished` is the benign close that - // follows `[DONE]`; drop it. Before completion it's a real - // transport failure (a dropped or stalled connection) that must - // surface so the retry layer can act on it. - if state.finished { Ok(vec![]) } else { Err(e) } - } - } -} - -/// Push a reasoning flush event if we haven't already. -fn flush_reasoning_if_needed(events: &mut Vec>, flushed: &mut bool) { - if !*flushed { - events.push(Ok(Event::flush(0))); - *flushed = true; - } -} - -/// Drain accumulated content from the `ReasoningExtractor` into events. -/// -/// Index convention matches Ollama: 0 = reasoning, 1 = message content. -fn drain_extractor(extractor: &mut ReasoningExtractor, is_structured: bool) -> Vec { - let mut events = Vec::new(); - - if !extractor.reasoning.is_empty() { - let reasoning = mem::take(&mut extractor.reasoning); - events.push(Event::reasoning(0, reasoning)); - } - - if !extractor.other.is_empty() { - let content = mem::take(&mut extractor.other); - if is_structured { - events.push(Event::structured(1, content)); - } else { - events.push(Event::message(1, content)); - } - } - - events -} - #[cfg(test)] impl Llamacpp { /// Build the llama.cpp wire request for `query` and serialize it to JSON @@ -561,97 +256,6 @@ fn create_request(model: &ModelDetails, query: ChatQuery) -> Result<(Value, bool Ok((body, is_structured)) } -/// Convert system prompt parts into a list of JSON message values. -fn to_system_messages(parts: Vec) -> impl Iterator { - parts - .into_iter() - .map(|content| json!({ "role": "system", "content": content })) -} - -/// Convert a conversation event stream into a list of JSON message values. -fn convert_events(events: ConversationStream) -> Vec { - let messages = events - .into_iter() - .filter_map(|event| match event.into_kind() { - EventKind::ChatRequest(request) => { - Some(json!({ "role": "user", "content": request.content })) - } - EventKind::ChatResponse(response) => match response { - ChatResponse::Message { message } => { - Some(json!({ "role": "assistant", "content": message })) - } - ChatResponse::Reasoning { reasoning } => { - // Use the `reasoning_content` field so the server can - // apply the correct template formatting. This avoids - // manually wrapping in `` tags. - Some(json!({ - "role": "assistant", - "reasoning_content": reasoning, - })) - } - ChatResponse::Structured { data } => { - Some(json!({ "role": "assistant", "content": data.to_string() })) - } - }, - EventKind::ToolCallRequest(request) => Some(json!({ - "role": "assistant", - "tool_calls": [{ - "id": request.id, - "type": "function", - "function": { - "name": request.name, - "arguments": Value::Object(request.arguments).to_string(), - }, - }], - })), - EventKind::ToolCallResponse(ToolCallResponse { id, result }) => Some(json!({ - "role": "tool", - "tool_call_id": id, - "content": match result { - Ok(content) | Err(content) => content, - }, - })), - _ => None, - }) - .collect(); - - merge_consecutive_assistant_messages(messages) -} - -/// Convert tool definitions to the OpenAI-compatible JSON format. -/// -/// If [`ToolChoice::Function`] is set, only include the named tool. llama.cpp -/// doesn't support calling a specific tool by name, but it supports `required` -/// mode, so we limit the tool list instead. -fn convert_tools(tools: Vec, tool_choice: &ToolChoice) -> Vec { - tools - .into_iter() - .map(|tool| { - json!({ - "type": "function", - "function": { - "name": tool.name, - "description": tool.docs.schema_description().unwrap_or_default(), - "parameters": parameters_with_strict_mode(&tool.parameters, true), - "strict": true, - }, - }) - }) - .filter(|tool| match tool_choice { - ToolChoice::Function(req) => tool["function"]["name"].as_str() == Some(req.as_str()), - _ => true, - }) - .collect() -} - -fn convert_tool_choice(choice: &ToolChoice) -> &str { - match choice { - ToolChoice::Auto => "auto", - ToolChoice::None => "none", - ToolChoice::Required | ToolChoice::Function(_) => "required", - } -} - impl Llamacpp { /// The context size the server was launched with, from `/props`. /// diff --git a/crates/jp_llm/src/provider/llamacpp_tests.rs b/crates/jp_llm/src/provider/llamacpp_tests.rs index 622fdf8b5..fcb316f3e 100644 --- a/crates/jp_llm/src/provider/llamacpp_tests.rs +++ b/crates/jp_llm/src/provider/llamacpp_tests.rs @@ -1,10 +1,7 @@ -use eventsource_stream::Event as MessageEvent; -use futures::StreamExt as _; -use jp_conversation::ConversationEvent; -use reqwest_eventsource::Error as SseError; +use jp_config::assistant::tool_choice::ToolChoice; use super::*; -use crate::{event::EventPart, provider::openai_compat::StreamChunk}; +use crate::provider::openai_compat::StreamChunk; fn qwen_model() -> LlamacppModel { serde_json::from_value(serde_json::json!({ @@ -121,66 +118,6 @@ fn create_request_asks_the_template_to_skip_thinking_when_reasoning_is_off() { ); } -fn sse_message(data: &str) -> SseEvent { - SseEvent::Message(MessageEvent { - data: data.to_owned(), - ..MessageEvent::default() - }) -} - -fn flush_indices(events: &[Result]) -> Vec { - events - .iter() - .filter_map(|e| match e { - Ok(Event::Flush { index, .. }) => Some(*index), - _ => None, - }) - .collect() -} - -#[test_log::test(tokio::test)] -async fn surfaces_stream_error_before_completion() { - // A transport error before `[DONE]` (a dropped or stalled connection) must - // surface as a `StreamError` so the retry layer can act on it, rather than - // being silently swallowed. - let content = sse_message( - r#"{"choices":[{"delta":{"content":"partial"},"index":0,"finish_reason":null}]}"#, - ); - let events = stream::iter(vec![Ok(content), Err(SseError::StreamEnded)]); - - let out: Vec<_> = assemble_event_stream(events, false).collect().await; - - assert!( - out.iter().any(std::result::Result::is_err), - "pre-completion stream error must surface, got {out:?}", - ); -} - -#[test_log::test(tokio::test)] -async fn swallows_stream_error_after_completion() { - // The connection close that follows `[DONE]` is the benign EOF; once the - // stream has emitted `Finished` it must not be surfaced as an error. - let content = - sse_message(r#"{"choices":[{"delta":{"content":"hi"},"index":0,"finish_reason":"stop"}]}"#); - let events = stream::iter(vec![ - Ok(content), - Ok(sse_message("[DONE]")), - Err(SseError::StreamEnded), - ]); - - let out: Vec<_> = assemble_event_stream(events, false).collect().await; - - assert!( - out.iter().all(std::result::Result::is_ok), - "post-completion close must not surface an error, got {out:?}", - ); - assert!( - matches!(out.last(), Some(Ok(Event::Finished(_)))), - "stream must end with Finished, got {:?}", - out.last(), - ); -} - #[test] fn parse_deepseek_format_reasoning_in_dedicated_field() { // The default `--reasoning-format deepseek`: reasoning arrives in @@ -307,268 +244,3 @@ fn parse_missing_optional_fields() { assert!(delta.tool_calls.is_none()); assert!(chunk.choices[0].finish_reason.is_none()); } - -#[test] -fn convert_events_merges_consecutive_tool_calls() { - use jp_conversation::event::ToolCallRequest; - - let mut events = ConversationStream::new_test(); - events.extend([ - ConversationEvent::now(ToolCallRequest { - id: "call_1".into(), - name: "tool_a".into(), - arguments: serde_json::Map::new(), - }), - ConversationEvent::now(ToolCallRequest { - id: "call_2".into(), - name: "tool_b".into(), - arguments: serde_json::Map::new(), - }), - ]); - - let messages = convert_events(events); - - // Should be merged into a single assistant message with 2 tool_calls. - assert_eq!(messages.len(), 1); - let tool_calls = messages[0]["tool_calls"].as_array().unwrap(); - assert_eq!(tool_calls.len(), 2); - assert_eq!(tool_calls[0]["function"]["name"], "tool_a"); - assert_eq!(tool_calls[1]["function"]["name"], "tool_b"); -} - -#[test] -fn convert_events_sends_reasoning_content_field() { - let mut events = ConversationStream::new_test(); - events.extend(std::iter::once(ConversationEvent::now( - ChatResponse::reasoning("step 1: think hard"), - ))); - - let messages = convert_events(events); - - assert_eq!(messages.len(), 1); - assert_eq!( - messages[0]["reasoning_content"].as_str().unwrap(), - "step 1: think hard" - ); -} - -#[test] -fn convert_events_merges_reasoning_and_message() { - let mut events = ConversationStream::new_test(); - events.extend([ - ConversationEvent::now(ChatResponse::reasoning("let me think...")), - ConversationEvent::now(ChatResponse::message("the answer is 42")), - ]); - - let messages = convert_events(events); - - // Reasoning + message should be merged into a single assistant message. - assert_eq!(messages.len(), 1); - assert_eq!( - messages[0]["reasoning_content"].as_str().unwrap(), - "let me think..." - ); - assert_eq!(messages[0]["content"].as_str().unwrap(), "the answer is 42"); -} - -#[test] -fn convert_tool_choice_values() { - assert_eq!(convert_tool_choice(&ToolChoice::Auto), "auto"); - assert_eq!(convert_tool_choice(&ToolChoice::None), "none"); - assert_eq!(convert_tool_choice(&ToolChoice::Required), "required"); - assert_eq!( - convert_tool_choice(&ToolChoice::Function("my_fn".into())), - "required" - ); -} - -/// `finish_reason: "length"` followed by `[DONE]` must not flush any pending -/// tool-call buffers. -/// When the model hits the token limit mid-tool-call, the arguments are -/// structurally incomplete; the safety-net drain on `[DONE]` would otherwise -/// commit them with truncated JSON (degraded to `{}`), which could re-dispatch -/// a partial call. -#[test] -fn length_finish_reason_drops_pending_tool_calls() { - let mut state = StreamState { - extractor: ReasoningExtractor::default(), - tool_call_indices: Vec::new(), - reasoning_flushed: false, - message_flushed: false, - finished: false, - finish_reason: None, - is_structured: false, - }; - - // Tool call delta with partial arguments. - let tool_chunk = r#"{ - "choices": [{ - "delta": { - "tool_calls": [{ - "index": 0, - "id": "call_abc", - "function": { "name": "run_me", "arguments": "{\"path\":" } - }] - }, - "index": 0, - "finish_reason": null - }] - }"#; - handle_sse_event_sync(Ok(sse_message(tool_chunk)), &mut state).unwrap(); - assert_eq!(state.tool_call_indices, vec![2]); - - // Terminal `"length"` chunk: should clear the pending tool-call index so - // the `[DONE]` safety net cannot commit the truncated buffer. - let finish_chunk = r#"{ - "choices": [{ - "delta": {}, - "index": 0, - "finish_reason": "length" - }] - }"#; - let finish_events = handle_sse_event_sync(Ok(sse_message(finish_chunk)), &mut state).unwrap(); - // Reasoning was already flushed when the tool-call chunk arrived, so only - // the message index flushes here. The tool-call index must NOT be in this - // list — that's the bug guard. - assert_eq!( - flush_indices(&finish_events), - vec![1], - "only message index should flush on length, got {finish_events:?}" - ); - assert!( - state.tool_call_indices.is_empty(), - "length must drop pending tool-call indices, got {:?}", - state.tool_call_indices, - ); - assert_eq!(state.finish_reason, Some(FinishReason::MaxTokens)); - - // `[DONE]` safety net: must NOT flush the tool-call index, and must - // finish with MaxTokens. - let done_events = handle_sse_event_sync(Ok(sse_message("[DONE]")), &mut state).unwrap(); - assert!( - flush_indices(&done_events).is_empty(), - "[DONE] after length must not flush any indices, got {done_events:?}" - ); - let last = done_events.last().unwrap().as_ref().unwrap(); - assert!( - matches!(last, Event::Finished(FinishReason::MaxTokens)), - "expected Finished(MaxTokens), got {last:?}" - ); -} - -/// A tool-call frame must release the extractor's held-back tail before -/// emitting any tool-call parts. -/// -/// The `ReasoningExtractor` withholds the last bytes of content (one less than -/// the `\n` opener) in case a tag is split across frames. -/// Downstream drains the in-progress markdown paragraph at the tool-call -/// boundary, so if the tail were released after `ToolCallPart::Start`, it would -/// land in a fresh paragraph and render as a mid-word blank-line split (e.g. -/// `…directo` then a blank line then `ries.`). -#[test] -fn tool_call_frame_releases_extractor_tail_before_tool_call_parts() { - let mut state = StreamState { - extractor: ReasoningExtractor::default(), - tool_call_indices: Vec::new(), - reasoning_flushed: false, - message_flushed: false, - finished: false, - finish_reason: None, - is_structured: false, - }; - - // A full paragraph in one frame, ending in a word long enough that the - // hold-back window splits it. - let content = - "Let me first check what tools are available to me for reading files and directories.\n\n"; - let content_chunk = serde_json::json!({ - "choices": [{ - "delta": { "content": content }, - "index": 0, - "finish_reason": null - }] - }); - let content_events = - handle_sse_event_sync(Ok(sse_message(&content_chunk.to_string())), &mut state).unwrap(); - - // The content frame withholds the tail while tag detection stays armed. - let content_emitted: String = content_events - .iter() - .filter_map(|e| match e.as_ref().ok() { - Some(Event::Part { - part: EventPart::Message(text), - .. - }) => Some(text.clone()), - _ => None, - }) - .collect(); - assert!( - !content_emitted.ends_with("directories.\n\n"), - "the tail should still be held back after the content frame: {content_emitted:?}" - ); - - // The tool-call frame releases the tail... - let tool_chunk = serde_json::json!({ - "choices": [{ - "delta": { - "tool_calls": [{ - "index": 0, - "id": "call_1", - "function": { "name": "describe_tools", "arguments": "{}" } - }] - }, - "index": 0, - "finish_reason": "tool_calls" - }] - }); - let tool_events = - handle_sse_event_sync(Ok(sse_message(&tool_chunk.to_string())), &mut state).unwrap(); - - let tail: String = tool_events - .iter() - .filter_map(|e| match e.as_ref().ok() { - Some(Event::Part { - part: EventPart::Message(text), - .. - }) => Some(text.clone()), - _ => None, - }) - .collect(); - assert_eq!( - format!("{content_emitted}{tail}"), - content, - "content must be preserved across the tool-call boundary" - ); - - // ...and it must precede every tool-call part in the emitted order, so the - // downstream paragraph drain at the tool-call boundary sees the complete - // paragraph. - let first_tool_call = tool_events - .iter() - .position(|e| { - matches!( - e.as_ref().ok(), - Some(Event::Part { - part: EventPart::ToolCall(_), - .. - }) - ) - }) - .unwrap(); - let last_message = tool_events - .iter() - .rposition(|e| { - matches!( - e.as_ref().ok(), - Some(Event::Part { - part: EventPart::Message(_), - .. - }) - ) - }) - .unwrap(); - assert!( - last_message < first_tool_call, - "the extractor tail must be emitted before the tool-call parts, got {tool_events:?}" - ); -} diff --git a/crates/jp_llm/src/provider/openai_compat.rs b/crates/jp_llm/src/provider/openai_compat.rs index 289963afc..ddb087bda 100644 --- a/crates/jp_llm/src/provider/openai_compat.rs +++ b/crates/jp_llm/src/provider/openai_compat.rs @@ -20,9 +20,26 @@ //! `StreamChoice::delta` is the one required field; a chunk whose choice omits //! it fails to parse, and both providers log a warning and skip that chunk. +use std::mem; + +use futures::{Stream, StreamExt as _, future, stream}; +use jp_config::assistant::tool_choice::ToolChoice; +use jp_conversation::{ + ConversationStream, + event::{ChatResponse, EventKind, ToolCallResponse}, +}; +use reqwest_eventsource::Event as SseEvent; use serde::Deserialize; use serde_json::{Value, json}; -use tracing::{debug, warn}; +use tracing::{debug, trace, warn}; + +use super::{EventStream, openai::parameters_with_strict_mode}; +use crate::{ + error::StreamError, + event::{Event, FinishReason}, + stream::aggregator::reasoning::ReasoningExtractor, + tool::ToolDefinition, +}; #[derive(Debug, Deserialize)] pub(crate) struct StreamChunk { @@ -169,6 +186,406 @@ pub(crate) fn merge_consecutive_assistant_messages(messages: Vec) -> Vec< }) } +/// Convert system prompt parts into a list of JSON message values. +pub(crate) fn to_system_messages(parts: Vec) -> impl Iterator { + parts + .into_iter() + .map(|content| json!({ "role": "system", "content": content })) +} + +/// Convert a conversation event stream into a list of JSON message values. +pub(crate) fn convert_events(events: ConversationStream) -> Vec { + let messages = events + .into_iter() + .filter_map(|event| match event.into_kind() { + EventKind::ChatRequest(request) => { + Some(json!({ "role": "user", "content": request.content })) + } + EventKind::ChatResponse(response) => match response { + ChatResponse::Message { message } => { + Some(json!({ "role": "assistant", "content": message })) + } + ChatResponse::Reasoning { reasoning } => { + // Use the `reasoning_content` field so the server can + // apply the correct template formatting. This avoids + // manually wrapping in `` tags. + Some(json!({ + "role": "assistant", + "reasoning_content": reasoning, + })) + } + ChatResponse::Structured { data } => { + Some(json!({ "role": "assistant", "content": data.to_string() })) + } + }, + EventKind::ToolCallRequest(request) => Some(json!({ + "role": "assistant", + "tool_calls": [{ + "id": request.id, + "type": "function", + "function": { + "name": request.name, + "arguments": Value::Object(request.arguments).to_string(), + }, + }], + })), + EventKind::ToolCallResponse(ToolCallResponse { id, result }) => Some(json!({ + "role": "tool", + "tool_call_id": id, + "content": match result { + Ok(content) | Err(content) => content, + }, + })), + _ => None, + }) + .collect(); + + merge_consecutive_assistant_messages(messages) +} + +/// Convert tool definitions to the OpenAI-compatible JSON format. +/// +/// If [`ToolChoice::Function`] is set, only include the named tool. +/// These servers don't support calling a specific tool by name, but they +/// support `required` mode, so we limit the tool list instead. +pub(crate) fn convert_tools(tools: Vec, tool_choice: &ToolChoice) -> Vec { + tools + .into_iter() + .map(|tool| { + json!({ + "type": "function", + "function": { + "name": tool.name, + "description": tool.docs.schema_description().unwrap_or_default(), + "parameters": parameters_with_strict_mode(&tool.parameters, true), + "strict": true, + }, + }) + }) + .filter(|tool| match tool_choice { + ToolChoice::Function(req) => tool["function"]["name"].as_str() == Some(req.as_str()), + _ => true, + }) + .collect() +} + +pub(crate) fn convert_tool_choice(choice: &ToolChoice) -> &'static str { + match choice { + ToolChoice::Auto => "auto", + ToolChoice::None => "none", + ToolChoice::Required | ToolChoice::Function(_) => "required", + } +} + +/// Assemble the provider-agnostic event stream from a raw SSE event source. +/// +/// `provider` names the server in log lines. +pub(crate) fn assemble_event_stream( + events: S, + provider: &'static str, + is_structured: bool, +) -> EventStream +where + S: Stream> + Send + 'static, +{ + let mut state = StreamState::new(provider, is_structured); + + let mut seen_error = false; + events + .take_while(move |event| { + // Include the first error before stopping: it must reach the + // handler below to be surfaced (or dropped once finished), and + // stopping prevents the EventSource from reconnecting after a + // terminal error. + let keep = !seen_error; + if event.is_err() { + seen_error = true; + } + future::ready(keep) + }) + .then(move |event| { + let result = handle_sse_event_sync(event, &mut state); + async move { + match result { + Ok(v) => stream::iter(v).boxed(), + Err(e) => { + stream::iter(vec![Err(StreamError::from_eventsource(e).await)]).boxed() + } + } + } + }) + .flatten() + .boxed() +} + +/// Mutable state carried across SSE events in a single stream. +pub(crate) struct StreamState { + /// The server name for log lines. + provider: &'static str, + extractor: ReasoningExtractor, + /// Tracks which tool call indices have been seen, so we can flush them on + /// finish. + pub(crate) tool_call_indices: Vec, + reasoning_flushed: bool, + /// Tracks whether `Event::flush(1)` (the message/structured index) has + /// already been emitted in this stream. + /// Without this gate, the `finish_reason` chunk and the `[DONE]` sentinel + /// both emit it, producing a spurious second flush that downstream + /// consumers can misinterpret as a re-dispatch signal. + message_flushed: bool, + /// Whether the terminal `Finished` event has been emitted. + /// Once set, a subsequent stream error is the benign connection close that + /// follows `[DONE]` and is dropped rather than surfaced to the retry layer. + finished: bool, + /// Captured from `finish_reason` in the last choice delta. + /// Emitted as `Event::Finished` when the `[DONE]` sentinel arrives. + pub(crate) finish_reason: Option, + is_structured: bool, +} + +impl StreamState { + pub(crate) fn new(provider: &'static str, is_structured: bool) -> Self { + Self { + provider, + extractor: ReasoningExtractor::default(), + tool_call_indices: Vec::new(), + reasoning_flushed: false, + message_flushed: false, + finished: false, + finish_reason: None, + is_structured, + } + } +} + +type SseResult = std::result::Result>, reqwest_eventsource::Error>; + +/// Process a single SSE event into zero or more provider-agnostic events. +#[expect(clippy::too_many_lines)] +pub(crate) fn handle_sse_event_sync( + event: Result, + state: &mut StreamState, +) -> SseResult { + match event { + Ok(SseEvent::Open) => Ok(vec![]), + Ok(SseEvent::Message(msg)) => { + trace!(provider = state.provider, event = %msg.data, "Received event."); + + if msg.data == "[DONE]" { + // Finalize the reasoning extractor on stream end. + state.extractor.finalize(); + let mut events: Vec> = + drain_extractor(&mut state.extractor, state.is_structured) + .into_iter() + .map(Ok) + .collect(); + + // Flush reasoning if we never did. + if !state.reasoning_flushed { + events.push(Ok(Event::flush(0))); + state.reasoning_flushed = true; + } + + // Flush message content if we never did. + if !state.message_flushed { + events.push(Ok(Event::flush(1))); + state.message_flushed = true; + } + + // Drain any tool call indices that weren't flushed via + // `finish_reason`. In well-behaved streams this is empty — + // the safety net guards against a missing `finish_reason` + // chunk that would otherwise orphan the tool call buffer. + for index in state.tool_call_indices.drain(..) { + events.push(Ok(Event::flush(index))); + } + + events.push(Ok(Event::Finished( + state + .finish_reason + .take() + .unwrap_or(FinishReason::Completed), + ))); + state.finished = true; + return Ok(events); + } + + let Some(chunk) = parse_chunk(&msg.data, state.provider) else { + return Ok(vec![]); + }; + + let mut events = Vec::new(); + + for choice in &chunk.choices { + let delta = &choice.delta; + + // Reasoning via `reasoning_content` (deepseek / deepseek-legacy formats) + if let Some(reasoning) = &delta.reasoning_content + && !reasoning.is_empty() + { + events.push(Ok(Event::reasoning(0, reasoning.clone()))); + } + + // Content + // + // If reasoning_content was present, the server already + // separated reasoning from content (deepseek / + // deepseek-legacy). Otherwise, content may contain tags + // (none format) and needs the extractor. + if let Some(content) = &delta.content + && !content.is_empty() + { + // Server separated reasoning; content is pure text. + if delta.reasoning_content.is_some() { + flush_reasoning_if_needed(&mut events, &mut state.reasoning_flushed); + + if state.is_structured { + events.push(Ok(Event::structured(1, content.clone()))); + } else { + events.push(Ok(Event::message(1, content.clone()))); + } + } else { + // Might contain tags — feed through extractor. + state.extractor.handle(content); + events.extend( + drain_extractor(&mut state.extractor, state.is_structured) + .into_iter() + .map(Ok), + ); + } + } + + // Tool calls + if delta.tool_calls.is_some() { + // A tool call terminates this message's content. Release + // any extractor-held tail now, before the tool-call parts + // are emitted: downstream drains the in-progress markdown + // paragraph at the tool-call boundary, so a tail released + // afterwards would land in a fresh paragraph and render as + // a mid-word blank-line split. + state.extractor.finalize(); + events.extend( + drain_extractor(&mut state.extractor, state.is_structured) + .into_iter() + .map(Ok), + ); + } + + if let Some(tool_calls) = &delta.tool_calls { + flush_reasoning_if_needed(&mut events, &mut state.reasoning_flushed); + + for tc in tool_calls { + let index = tc.index as usize + 2; + + if !state.tool_call_indices.contains(&index) { + state.tool_call_indices.push(index); + } + + let id = tc.id.clone().unwrap_or_default(); + let name = tc + .function + .as_ref() + .and_then(|f| f.name.clone()) + .unwrap_or_default(); + if !id.is_empty() || !name.is_empty() { + events.push(Ok(Event::tool_call_start(index, id, name))); + } + + if let Some(args) = + tc.function.as_ref().and_then(|f| f.arguments.as_deref()) + { + events.push(Ok(Event::tool_call_args(index, args))); + } + } + } + + // Finish reason + if let Some(reason) = &choice.finish_reason { + state.extractor.finalize(); + events.extend( + drain_extractor(&mut state.extractor, state.is_structured) + .into_iter() + .map(Ok), + ); + + // Flush reasoning and message content before tool calls + // so they appear earlier in the conversation history. + if !state.reasoning_flushed { + events.push(Ok(Event::flush(0))); + state.reasoning_flushed = true; + } + if !state.message_flushed { + events.push(Ok(Event::flush(1))); + state.message_flushed = true; + } + + if matches!(reason.as_str(), "tool_calls" | "stop") { + for index in state.tool_call_indices.drain(..) { + events.push(Ok(Event::flush(index))); + } + } + + // Per the OpenAI spec. + match reason.as_str() { + "length" => { + // Active tool-call blocks are structurally + // incomplete when the model hits the token + // limit. Drop them here so the `[DONE]` safety + // net does not commit truncated arguments — + // mirrors `EventBuilder::drain` and the Google + // provider's MaxTokens behaviour. + state.tool_call_indices.clear(); + state.finish_reason = Some(FinishReason::MaxTokens); + } + "stop" => state.finish_reason = Some(FinishReason::Completed), + _ => {} + } + } + } + + Ok(events) + } + Err(e) => { + // A stream error after `Finished` is the benign close that + // follows `[DONE]`; drop it. Before completion it's a real + // transport failure (a dropped or stalled connection) that must + // surface so the retry layer can act on it. + if state.finished { Ok(vec![]) } else { Err(e) } + } + } +} + +/// Push a reasoning flush event if we haven't already. +fn flush_reasoning_if_needed(events: &mut Vec>, flushed: &mut bool) { + if !*flushed { + events.push(Ok(Event::flush(0))); + *flushed = true; + } +} + +/// Drain accumulated content from the `ReasoningExtractor` into events. +/// +/// Index convention matches Ollama: 0 = reasoning, 1 = message content. +fn drain_extractor(extractor: &mut ReasoningExtractor, is_structured: bool) -> Vec { + let mut events = Vec::new(); + + if !extractor.reasoning.is_empty() { + let reasoning = mem::take(&mut extractor.reasoning); + events.push(Event::reasoning(0, reasoning)); + } + + if !extractor.other.is_empty() { + let content = mem::take(&mut extractor.other); + if is_structured { + events.push(Event::structured(1, content)); + } else { + events.push(Event::message(1, content)); + } + } + + events +} + #[cfg(test)] #[path = "openai_compat_tests.rs"] mod tests; diff --git a/crates/jp_llm/src/provider/openai_compat_tests.rs b/crates/jp_llm/src/provider/openai_compat_tests.rs index 73e4b5389..4528f692c 100644 --- a/crates/jp_llm/src/provider/openai_compat_tests.rs +++ b/crates/jp_llm/src/provider/openai_compat_tests.rs @@ -1,6 +1,317 @@ +use eventsource_stream::Event as MessageEvent; +use jp_conversation::{ConversationEvent, event::ToolCallRequest}; +use reqwest_eventsource::Error as SseError; use serde_json::json; use super::*; +use crate::event::EventPart; + +fn sse_message(data: &str) -> SseEvent { + SseEvent::Message(MessageEvent { + data: data.to_owned(), + ..MessageEvent::default() + }) +} + +fn flush_indices(events: &[Result]) -> Vec { + events + .iter() + .filter_map(|e| match e { + Ok(Event::Flush { index, .. }) => Some(*index), + _ => None, + }) + .collect() +} + +#[test_log::test(tokio::test)] +async fn surfaces_stream_error_before_completion() { + // A transport error before `[DONE]` (a dropped or stalled connection) must + // surface as a `StreamError` so the retry layer can act on it, rather than + // being silently swallowed. + let content = sse_message( + r#"{"choices":[{"delta":{"content":"partial"},"index":0,"finish_reason":null}]}"#, + ); + let events = stream::iter(vec![Ok(content), Err(SseError::StreamEnded)]); + + let out: Vec<_> = assemble_event_stream(events, "test", false).collect().await; + + assert!( + out.iter().any(std::result::Result::is_err), + "pre-completion stream error must surface, got {out:?}", + ); +} + +#[test_log::test(tokio::test)] +async fn swallows_stream_error_after_completion() { + // The connection close that follows `[DONE]` is the benign EOF; once the + // stream has emitted `Finished` it must not be surfaced as an error. + let content = + sse_message(r#"{"choices":[{"delta":{"content":"hi"},"index":0,"finish_reason":"stop"}]}"#); + let events = stream::iter(vec![ + Ok(content), + Ok(sse_message("[DONE]")), + Err(SseError::StreamEnded), + ]); + + let out: Vec<_> = assemble_event_stream(events, "test", false).collect().await; + + assert!( + out.iter().all(std::result::Result::is_ok), + "post-completion close must not surface an error, got {out:?}", + ); + assert!( + matches!(out.last(), Some(Ok(Event::Finished(_)))), + "stream must end with Finished, got {:?}", + out.last(), + ); +} + +/// `finish_reason: "length"` followed by `[DONE]` must not flush any pending +/// tool-call buffers. +/// When the model hits the token limit mid-tool-call, the arguments are +/// structurally incomplete; the safety-net drain on `[DONE]` would otherwise +/// commit them with truncated JSON (degraded to `{}`), which could re-dispatch +/// a partial call. +#[test] +fn length_finish_reason_drops_pending_tool_calls() { + let mut state = StreamState::new("test", false); + + // Tool call delta with partial arguments. + let tool_chunk = r#"{ + "choices": [{ + "delta": { + "tool_calls": [{ + "index": 0, + "id": "call_abc", + "function": { "name": "run_me", "arguments": "{\"path\":" } + }] + }, + "index": 0, + "finish_reason": null + }] + }"#; + handle_sse_event_sync(Ok(sse_message(tool_chunk)), &mut state).unwrap(); + assert_eq!(state.tool_call_indices, vec![2]); + + // Terminal `"length"` chunk: should clear the pending tool-call index so + // the `[DONE]` safety net cannot commit the truncated buffer. + let finish_chunk = r#"{ + "choices": [{ + "delta": {}, + "index": 0, + "finish_reason": "length" + }] + }"#; + let finish_events = handle_sse_event_sync(Ok(sse_message(finish_chunk)), &mut state).unwrap(); + // Reasoning was already flushed when the tool-call chunk arrived, so only + // the message index flushes here. The tool-call index must NOT be in this + // list. + assert_eq!( + flush_indices(&finish_events), + vec![1], + "only message index should flush on length, got {finish_events:?}" + ); + assert!( + state.tool_call_indices.is_empty(), + "length must drop pending tool-call indices, got {:?}", + state.tool_call_indices, + ); + assert_eq!(state.finish_reason, Some(FinishReason::MaxTokens)); + + // `[DONE]` safety net: must NOT flush the tool-call index, and must + // finish with MaxTokens. + let done_events = handle_sse_event_sync(Ok(sse_message("[DONE]")), &mut state).unwrap(); + assert!( + flush_indices(&done_events).is_empty(), + "[DONE] after length must not flush any indices, got {done_events:?}" + ); + let last = done_events.last().unwrap().as_ref().unwrap(); + assert!( + matches!(last, Event::Finished(FinishReason::MaxTokens)), + "expected Finished(MaxTokens), got {last:?}" + ); +} + +/// A tool-call frame must release the extractor's held-back tail before +/// emitting any tool-call parts. +/// +/// The `ReasoningExtractor` withholds the last bytes of content (one less than +/// the `\n` opener) in case a tag is split across frames. +/// Downstream drains the in-progress markdown paragraph at the tool-call +/// boundary, so if the tail were released after `ToolCallPart::Start`, it would +/// land in a fresh paragraph and render as a mid-word blank-line split (e.g. +/// `…directo` then a blank line then `ries.`). +#[test] +fn tool_call_frame_releases_extractor_tail_before_tool_call_parts() { + let mut state = StreamState::new("test", false); + + // A full paragraph in one frame, ending in a word long enough that the + // hold-back window splits it. + let content = + "Let me first check what tools are available to me for reading files and directories.\n\n"; + let content_chunk = json!({ + "choices": [{ + "delta": { "content": content }, + "index": 0, + "finish_reason": null + }] + }); + let content_events = + handle_sse_event_sync(Ok(sse_message(&content_chunk.to_string())), &mut state).unwrap(); + + // The content frame withholds the tail while tag detection stays armed. + let content_emitted: String = content_events + .iter() + .filter_map(|e| match e.as_ref().ok() { + Some(Event::Part { + part: EventPart::Message(text), + .. + }) => Some(text.clone()), + _ => None, + }) + .collect(); + assert!( + !content_emitted.ends_with("directories.\n\n"), + "the tail should still be held back after the content frame: {content_emitted:?}" + ); + + // The tool-call frame releases the tail... + let tool_chunk = json!({ + "choices": [{ + "delta": { + "tool_calls": [{ + "index": 0, + "id": "call_1", + "function": { "name": "describe_tools", "arguments": "{}" } + }] + }, + "index": 0, + "finish_reason": "tool_calls" + }] + }); + let tool_events = + handle_sse_event_sync(Ok(sse_message(&tool_chunk.to_string())), &mut state).unwrap(); + + let tail: String = tool_events + .iter() + .filter_map(|e| match e.as_ref().ok() { + Some(Event::Part { + part: EventPart::Message(text), + .. + }) => Some(text.clone()), + _ => None, + }) + .collect(); + assert_eq!( + format!("{content_emitted}{tail}"), + content, + "content must be preserved across the tool-call boundary" + ); + + // ...and it must precede every tool-call part in the emitted order, so the + // downstream paragraph drain at the tool-call boundary sees the complete + // paragraph. + let first_tool_call = tool_events + .iter() + .position(|e| { + matches!( + e.as_ref().ok(), + Some(Event::Part { + part: EventPart::ToolCall(_), + .. + }) + ) + }) + .unwrap(); + let last_message = tool_events + .iter() + .rposition(|e| { + matches!( + e.as_ref().ok(), + Some(Event::Part { + part: EventPart::Message(_), + .. + }) + ) + }) + .unwrap(); + assert!( + last_message < first_tool_call, + "the extractor tail must be emitted before the tool-call parts, got {tool_events:?}" + ); +} + +#[test] +fn convert_events_merges_consecutive_tool_calls() { + let mut events = ConversationStream::new_test(); + events.extend([ + ConversationEvent::now(ToolCallRequest { + id: "call_1".into(), + name: "tool_a".into(), + arguments: serde_json::Map::new(), + }), + ConversationEvent::now(ToolCallRequest { + id: "call_2".into(), + name: "tool_b".into(), + arguments: serde_json::Map::new(), + }), + ]); + + let messages = convert_events(events); + + // Should be merged into a single assistant message with 2 tool_calls. + assert_eq!(messages.len(), 1); + let tool_calls = messages[0]["tool_calls"].as_array().unwrap(); + assert_eq!(tool_calls.len(), 2); + assert_eq!(tool_calls[0]["function"]["name"], "tool_a"); + assert_eq!(tool_calls[1]["function"]["name"], "tool_b"); +} + +#[test] +fn convert_events_sends_reasoning_content_field() { + let mut events = ConversationStream::new_test(); + events.extend(std::iter::once(ConversationEvent::now( + ChatResponse::reasoning("step 1: think hard"), + ))); + + let messages = convert_events(events); + + assert_eq!(messages.len(), 1); + assert_eq!( + messages[0]["reasoning_content"].as_str().unwrap(), + "step 1: think hard" + ); +} + +#[test] +fn convert_events_merges_reasoning_and_message() { + let mut events = ConversationStream::new_test(); + events.extend([ + ConversationEvent::now(ChatResponse::reasoning("let me think...")), + ConversationEvent::now(ChatResponse::message("the answer is 42")), + ]); + + let messages = convert_events(events); + + // Reasoning + message should be merged into a single assistant message. + assert_eq!(messages.len(), 1); + assert_eq!( + messages[0]["reasoning_content"].as_str().unwrap(), + "let me think..." + ); + assert_eq!(messages[0]["content"].as_str().unwrap(), "the answer is 42"); +} + +#[test] +fn convert_tool_choice_values() { + assert_eq!(convert_tool_choice(&ToolChoice::Auto), "auto"); + assert_eq!(convert_tool_choice(&ToolChoice::None), "none"); + assert_eq!(convert_tool_choice(&ToolChoice::Required), "required"); + assert_eq!( + convert_tool_choice(&ToolChoice::Function("my_fn".into())), + "required" + ); +} /// A chunk carrying content parses and is handed back. #[test] diff --git a/crates/jp_llm/src/provider/vllm.rs b/crates/jp_llm/src/provider/vllm.rs new file mode 100644 index 000000000..c71a340e2 --- /dev/null +++ b/crates/jp_llm/src/provider/vllm.rs @@ -0,0 +1,315 @@ +//! The vLLM provider: a self-hosted server that speaks the OpenAI-compatible +//! `/v1/chat/completions` dialect and checks a Bearer token. + +use std::{env, time::Duration}; + +use async_trait::async_trait; +use base64::Engine as _; +use jp_attachment::AttachmentContent; +use jp_config::{ + model::{ + id::{ModelIdConfig, Name, ProviderId}, + parameters::ReasoningConfig, + }, + providers::llm::vllm::VllmConfig, +}; +use jp_conversation::thread::text_attachments_to_xml; +use reqwest::header::{self, HeaderMap, HeaderValue}; +use reqwest_eventsource::{EventSource, retry::Never}; +use serde::Deserialize; +use serde_json::{Value, json}; +use tracing::{debug, trace, warn}; + +use super::{ + EventStream, ModelDetails, + openai_compat::{ + assemble_event_stream, convert_events, convert_tool_choice, convert_tools, + to_system_messages, + }, + trace_to_tmpfile, +}; +use crate::{error::Error, provider::Provider, query::ChatQuery, stream::with_tool_call_keepalive}; + +static PROVIDER: ProviderId = ProviderId::Vllm; + +/// How often to inject a synthetic keep-alive while a tool call is streaming. +/// +/// Stays below the enforced minimum `stream_idle_timeout_secs` (10s) so the +/// heartbeat always lands before the idle window elapses if the model pauses +/// between argument chunks. +const TOOL_CALL_KEEPALIVE_INTERVAL: Duration = Duration::from_secs(5); + +#[derive(Debug, Clone)] +pub struct Vllm { + client: reqwest::Client, + base_url: String, +} + +#[async_trait] +impl Provider for Vllm { + async fn model_details(&self, name: &Name) -> Result { + let id: ModelIdConfig = (PROVIDER, name.as_ref()).try_into()?; + + Ok(self + .models() + .await? + .into_iter() + .find(|m| m.id == id) + .unwrap_or(ModelDetails::empty(id))) + } + + async fn models(&self) -> Result, Error> { + self.client + .get(format!("{}/v1/models", self.base_url)) + .send() + .await? + .error_for_status()? + .json::() + .await? + .data + .iter() + .map(map_model) + .collect::>() + } + + async fn chat_completion_stream( + &self, + model: &ModelDetails, + query: ChatQuery, + ) -> Result { + debug!(model = %model.id.name, "Starting vLLM chat completion stream."); + + let (body, is_structured) = create_request(model, query)?; + + trace!( + request = %trace_to_tmpfile("jp-vllm-request", &body), + "Request payload." + ); + + let request = self + .client + .post(format!("{}/v1/chat/completions", self.base_url)) + .header("content-type", "application/json") + .json(&body); + + let mut es = + EventSource::new(request).map_err(|e| Error::InvalidResponse(e.to_string()))?; + // Retries are owned by the stream retry layer; disable EventSource's + // own auto-reconnect so a closed connection ends the stream instead of + // silently re-issuing the request. + es.set_retry_policy(Box::new(Never)); + + Ok(with_tool_call_keepalive( + assemble_event_stream(es, "vllm", is_structured), + TOOL_CALL_KEEPALIVE_INTERVAL, + )) + } +} + +#[cfg(test)] +impl Vllm { + /// Build the vLLM wire request for `query` and serialize it to JSON without + /// sending. + /// Test-only seam for snapshotting request construction (notably compaction + /// projection) across providers. + #[expect( + clippy::unused_self, + reason = "uniform per-provider seam; only some providers read instance state" + )] + pub(crate) fn request_value( + &self, + model: &ModelDetails, + query: ChatQuery, + ) -> Result { + let (request, _) = create_request(model, query)?; + Ok(request) + } +} + +/// Build the JSON request body for the vLLM `/v1/chat/completions` endpoint. +/// +/// Returns `(body, is_structured)`. +fn create_request(model: &ModelDetails, query: ChatQuery) -> Result<(Value, bool), Error> { + let ChatQuery { + thread, + tools, + tool_choice, + } = query; + + let structured_schema = thread.events.schema(); + + let is_structured = structured_schema.is_some(); + let config = thread.events.config()?; + let parameters = &config.assistant.model.parameters; + let slug = model.id.name.to_string(); + + let parts = thread.into_parts(); + + let mut system_parts = parts.system_parts; + if let Some(xml) = text_attachments_to_xml(&parts.attachments)? { + system_parts.push(xml); + } + + let mut messages: Vec = to_system_messages(system_parts).collect(); + + // Prepend binary image attachments as a user message with image_url + // content blocks (OpenAI chat completions format). + let image_blocks: Vec<_> = parts + .attachments + .iter() + .filter_map(|a| match &a.content { + AttachmentContent::Binary { data, media_type } if media_type.starts_with("image/") => { + Some(json!({ + "type": "image_url", + "image_url": { + "url": format!( + "data:{media_type};base64,{}", + base64::engine::general_purpose::STANDARD.encode(data), + ), + }, + })) + } + AttachmentContent::Binary { media_type, .. } => { + warn!( + source = %a.source, + media_type, + "Unsupported binary attachment media type for vLLM, skipping." + ); + None + } + AttachmentContent::Text(_) => None, + }) + .collect(); + + if !image_blocks.is_empty() { + messages.push(json!({ + "role": "user", + "content": image_blocks, + })); + } + + messages.extend(convert_events(parts.events)); + let converted_tools = convert_tools(tools, &tool_choice); + let tool_choice_val = convert_tool_choice(&tool_choice); + + trace!( + slug, + messages_size = messages.len(), + tools_size = converted_tools.len(), + "Built vLLM request." + ); + + // Models such as Qwen3 default to thinking-on, so + // `chat_template_kwargs.enable_thinking` tells the chat template whether to + // prompt the model to think at all. Models whose template doesn't read the + // kwarg silently ignore it. + let reasoning_enabled = !matches!(parameters.reasoning, None | Some(ReasoningConfig::Off)); + + let mut body = json!({ + "model": slug, + "messages": messages, + "stream": true, + "chat_template_kwargs": { "enable_thinking": reasoning_enabled }, + }); + + if let Some(temperature) = parameters.temperature { + body["temperature"] = json!(temperature); + } + + if let Some(top_p) = parameters.top_p { + body["top_p"] = json!(top_p); + } + + if let Some(max_tokens) = parameters.max_tokens { + body["max_tokens"] = json!(max_tokens); + } + + if !converted_tools.is_empty() { + body["tools"] = json!(converted_tools); + body["tool_choice"] = json!(tool_choice_val); + } + + if let Some(schema) = structured_schema { + body["response_format"] = json!({ + "type": "json_schema", + "json_schema": { + "name": "structured_output", + "schema": schema, + "strict": true, + }, + }); + } + + Ok((body, is_structured)) +} + +/// A `/v1/models` listing from vLLM. +/// +/// vLLM serves the OpenAI shape and adds `max_model_len` per entry, which is +/// the context window the server was launched with. +#[derive(Debug, Deserialize)] +struct VllmModelList { + #[serde(default)] + data: Vec, +} + +#[derive(Debug, Deserialize)] +struct VllmModel { + id: String, + + /// The served context window. + /// + /// Absent on servers that omit the vLLM extension fields. + #[serde(default)] + max_model_len: Option, +} + +/// Map a vLLM model listing entry to model details. +/// +/// The id keeps its full form, for example `Qwen/Qwen3-8B`, because vLLM +/// accepts only that form in a request. +fn map_model(model: &VllmModel) -> Result { + Ok(ModelDetails { + id: (PROVIDER, model.id.as_str()).try_into()?, + display_name: None, + context_window: model.max_model_len, + // vLLM reports no generation ceiling; it is bounded by the served + // context rather than a per-model limit. + max_output_tokens: None, + // Reasoning is a server-launch concern for vLLM, selected with + // `--reasoning-parser` rather than reported per model, so support stays + // unknown and an explicit request is passed through. + reasoning: None, + knowledge_cutoff: None, + deprecated: None, + structured_output: None, + prefill: None, + features: vec![], + }) +} + +impl TryFrom<&VllmConfig> for Vllm { + type Error = Error; + + fn try_from(config: &VllmConfig) -> Result { + let api_key = env::var(&config.api_key_env) + .map_err(|_| Error::MissingEnv(config.api_key_env.clone()))?; + + let client = reqwest::Client::builder() + .default_headers(HeaderMap::from_iter([( + header::AUTHORIZATION, + HeaderValue::from_str(&format!("Bearer {api_key}")) + .map_err(|_| Error::InvalidResponse("invalid API key".into()))?, + )])) + .build()?; + + Ok(Vllm { + client, + base_url: config.base_url.clone(), + }) + } +} + +#[cfg(test)] +#[path = "vllm_tests.rs"] +mod tests; diff --git a/crates/jp_llm/src/provider/vllm_tests.rs b/crates/jp_llm/src/provider/vllm_tests.rs new file mode 100644 index 000000000..12c390b6b --- /dev/null +++ b/crates/jp_llm/src/provider/vllm_tests.rs @@ -0,0 +1,205 @@ +use jp_config::{assistant::tool_choice::ToolChoice, model::parameters::PartialReasoningConfig}; +use jp_conversation::{ + ConversationEvent, ConversationStream, + event::{ChatRequest, ChatResponse, ToolCallRequest, ToolCallResponse}, + thread::Thread, +}; +use serde_json::{Map, json}; + +use super::*; +use crate::tool::{ToolDefinition, ToolDocs}; + +fn qwen_model() -> VllmModel { + serde_json::from_value(json!({ + "id": "Qwen/Qwen3-8B", + "object": "model", + "owned_by": "vllm", + "max_model_len": 40_960, + })) + .unwrap() +} + +fn qwen_details() -> ModelDetails { + ModelDetails::empty((PROVIDER, "Qwen/Qwen3-8B").try_into().unwrap()) +} + +fn query(events: ConversationStream, tools: Vec) -> ChatQuery { + ChatQuery { + thread: Thread { + system_prompt: None, + sections: vec![], + attachments: vec![], + events, + }, + tools, + tool_choice: ToolChoice::Auto, + } +} + +/// vLLM reports the served context window as `max_model_len`, and the model id +/// keeps its vendor prefix because vLLM accepts only the full id. +#[test] +fn map_model_keeps_full_id_and_reads_max_model_len() { + let details = map_model(&qwen_model()).unwrap(); + + assert_eq!(details.id.name.as_ref(), "Qwen/Qwen3-8B"); + assert_eq!(details.context_window, Some(40_960)); + assert_eq!(details.reasoning, None); +} + +/// A plain message becomes one user message, with streaming on and thinking +/// off, because the test config has no reasoning setting. +#[test] +fn create_request_plain_message() { + let events = ConversationStream::new_test().with_turn("Hello"); + + let (body, is_structured) = create_request(&qwen_details(), query(events, vec![])).unwrap(); + + assert!(!is_structured); + assert_eq!( + body, + json!({ + "model": "Qwen/Qwen3-8B", + "messages": [{ "role": "user", "content": "Hello" }], + "stream": true, + "chat_template_kwargs": { "enable_thinking": false }, + }) + ); +} + +/// Whether the model thinks at all is the chat template's decision, driven by +/// `enable_thinking`. +#[test] +fn create_request_asks_the_template_to_think_when_reasoning_is_on() { + let mut events = ConversationStream::new_test().with_turn("Hello"); + let mut delta = jp_config::PartialAppConfig::empty(); + delta.assistant.model.parameters.reasoning = Some(PartialReasoningConfig::Auto); + events.add_config_delta(delta); + + let (body, _) = create_request(&qwen_details(), query(events, vec![])).unwrap(); + + assert_eq!( + body["chat_template_kwargs"], + json!({ "enable_thinking": true }) + ); + assert!(body.get("reasoning_format").is_none()); +} + +/// A tool call and its result become one assistant message with `tool_calls` +/// and one `tool` message, and the tool list uses the strict function shape. +#[test] +fn create_request_tool_call_round_trip() { + let mut events = ConversationStream::new_test().with_turn("Read the file"); + events.extend([ + ConversationEvent::now(ToolCallRequest { + id: "call_1".into(), + name: "read_file".into(), + arguments: serde_json::from_value(json!({ "path": "a.txt" })).unwrap(), + }), + ConversationEvent::now(ToolCallResponse { + id: "call_1".into(), + result: Ok("contents".into()), + }), + ]); + + let tool = ToolDefinition { + name: "read_file".into(), + docs: ToolDocs { + summary: Some("Read a file.".into()), + ..ToolDocs::default() + }, + parameters: json!({ + "type": "object", + "properties": { "path": { "type": "string" } }, + "required": ["path"], + }), + }; + + let (body, _) = create_request(&qwen_details(), query(events, vec![tool])).unwrap(); + + assert_eq!( + body["messages"], + json!([ + { "role": "user", "content": "Read the file" }, + { + "role": "assistant", + "tool_calls": [{ + "id": "call_1", + "type": "function", + "function": { "name": "read_file", "arguments": "{\"path\":\"a.txt\"}" }, + }], + }, + { "role": "tool", "tool_call_id": "call_1", "content": "contents" }, + ]) + ); + assert_eq!(body["tool_choice"], json!("auto")); + assert_eq!( + body["tools"], + json!([{ + "type": "function", + "function": { + "name": "read_file", + "description": "Read a file.", + "parameters": { + "type": "object", + "properties": { "path": { "type": "string" } }, + "required": ["path"], + "additionalProperties": false, + }, + "strict": true, + }, + }]) + ); +} + +/// A schema on the request becomes a strict `json_schema` response format. +#[test] +fn create_request_structured_schema() { + let schema: Map = serde_json::from_value(json!({ + "type": "object", + "properties": { "answer": { "type": "string" } }, + })) + .unwrap(); + let events = ConversationStream::new_test().with_turn(ChatRequest { + content: "Answer".into(), + schema: Some(schema), + author: None, + }); + + let (body, is_structured) = create_request(&qwen_details(), query(events, vec![])).unwrap(); + + assert!(is_structured); + assert_eq!( + body["response_format"], + json!({ + "type": "json_schema", + "json_schema": { + "name": "structured_output", + "schema": { + "type": "object", + "properties": { "answer": { "type": "string" } }, + }, + "strict": true, + }, + }) + ); +} + +/// The prior assistant reply stays a plain message in the history. +#[test] +fn create_request_keeps_assistant_history() { + let mut events = ConversationStream::new_test().with_turn("Hi"); + events.extend([ConversationEvent::now(ChatResponse::message("Hello!"))]); + let events = events.with_turn("Again"); + + let (body, _) = create_request(&qwen_details(), query(events, vec![])).unwrap(); + + assert_eq!( + body["messages"], + json!([ + { "role": "user", "content": "Hi" }, + { "role": "assistant", "content": "Hello!" }, + { "role": "user", "content": "Again" }, + ]) + ); +} diff --git a/crates/jp_llm/src/test.rs b/crates/jp_llm/src/test.rs index f83fbe8aa..858ffa724 100644 --- a/crates/jp_llm/src/test.rs +++ b/crates/jp_llm/src/test.rs @@ -473,6 +473,7 @@ pub async fn run_chat_completion( ProviderId::Ollama => config.ollama.base_url.clone(), ProviderId::Openai => config.openai.base_url.clone(), ProviderId::Openrouter => config.openrouter.base_url.clone(), + ProviderId::Vllm => config.vllm.base_url.clone(), _ => String::new(), }) .with_fixture_suffix(&provider_id.as_str()); @@ -493,6 +494,7 @@ pub async fn run_chat_completion( ProviderId::Ollama => config.ollama.base_url = url, ProviderId::Openai => config.openai.base_url = url, ProviderId::Openrouter => config.openrouter.base_url = url, + ProviderId::Vllm => config.vllm.base_url = url, _ => {} } @@ -506,6 +508,7 @@ pub async fn run_chat_completion( ProviderId::Google => config.google.api_key_env = env, ProviderId::Openai => config.openai.api_key_env = env, ProviderId::Openrouter => config.openrouter.api_key_env = env, + ProviderId::Vllm => config.vllm.api_key_env = env, _ => {} } } @@ -769,6 +772,10 @@ pub(crate) fn fixture_attachment(path: impl AsRef) -> Attachment { Attachment::binary(path.as_ref().display().to_string(), data, media_type) } +#[expect( + clippy::too_many_lines, + reason = "one arm per provider; the table is flat by design" +)] pub(crate) fn test_model_details(id: ProviderId) -> ModelDetails { match id { ProviderId::Anthropic => ModelDetails { @@ -857,6 +864,18 @@ pub(crate) fn test_model_details(id: ProviderId) -> ModelDetails { prefill: None, features: vec![], }, + ProviderId::Vllm => ModelDetails { + id: "vllm/Qwen/Qwen3-8B".parse().unwrap(), + display_name: None, + context_window: Some(40_960), + max_output_tokens: None, + reasoning: None, + knowledge_cutoff: None, + deprecated: None, + structured_output: None, + prefill: None, + features: vec![], + }, ProviderId::Test => ModelDetails::empty("test/mock-model".parse().unwrap()), ProviderId::Xai => unimplemented!(), ProviderId::Deepseek => unimplemented!(), diff --git a/crates/jp_llm/tests/fixtures/anthropic/test_chat_completion_stream__conversation_stream.snap b/crates/jp_llm/tests/fixtures/anthropic/test_chat_completion_stream__conversation_stream.snap index 02cfa1b45..53db4dd24 100644 --- a/crates/jp_llm/tests/fixtures/anthropic/test_chat_completion_stream__conversation_stream.snap +++ b/crates/jp_llm/tests/fixtures/anthropic/test_chat_completion_stream__conversation_stream.snap @@ -213,6 +213,10 @@ expression: v "api_key_env": "OPENROUTER_API_KEY", "app_name": "JP", "base_url": "https://openrouter.ai" + }, + "vllm": { + "api_key_env": "VLLM_API_KEY", + "base_url": "http://127.0.0.1:8000" } } }, diff --git a/crates/jp_llm/tests/fixtures/anthropic/test_fable_5_forced_tool_soft_forces__conversation_stream.snap b/crates/jp_llm/tests/fixtures/anthropic/test_fable_5_forced_tool_soft_forces__conversation_stream.snap index abbe792a4..10b3c912f 100644 --- a/crates/jp_llm/tests/fixtures/anthropic/test_fable_5_forced_tool_soft_forces__conversation_stream.snap +++ b/crates/jp_llm/tests/fixtures/anthropic/test_fable_5_forced_tool_soft_forces__conversation_stream.snap @@ -213,6 +213,10 @@ expression: v "api_key_env": "OPENROUTER_API_KEY", "app_name": "JP", "base_url": "https://openrouter.ai" + }, + "vllm": { + "api_key_env": "VLLM_API_KEY", + "base_url": "http://127.0.0.1:8000" } } }, diff --git a/crates/jp_llm/tests/fixtures/anthropic/test_image_attachment__conversation_stream.snap b/crates/jp_llm/tests/fixtures/anthropic/test_image_attachment__conversation_stream.snap index b212d5bc5..464de1cbd 100644 --- a/crates/jp_llm/tests/fixtures/anthropic/test_image_attachment__conversation_stream.snap +++ b/crates/jp_llm/tests/fixtures/anthropic/test_image_attachment__conversation_stream.snap @@ -210,6 +210,10 @@ expression: v "api_key_env": "OPENROUTER_API_KEY", "app_name": "JP", "base_url": "https://openrouter.ai" + }, + "vllm": { + "api_key_env": "VLLM_API_KEY", + "base_url": "http://127.0.0.1:8000" } } }, diff --git a/crates/jp_llm/tests/fixtures/anthropic/test_multi_turn_conversation__conversation_stream.snap b/crates/jp_llm/tests/fixtures/anthropic/test_multi_turn_conversation__conversation_stream.snap index 25ed49a0b..7ca0ba961 100644 --- a/crates/jp_llm/tests/fixtures/anthropic/test_multi_turn_conversation__conversation_stream.snap +++ b/crates/jp_llm/tests/fixtures/anthropic/test_multi_turn_conversation__conversation_stream.snap @@ -213,6 +213,10 @@ expression: v "api_key_env": "OPENROUTER_API_KEY", "app_name": "JP", "base_url": "https://openrouter.ai" + }, + "vllm": { + "api_key_env": "VLLM_API_KEY", + "base_url": "http://127.0.0.1:8000" } } }, diff --git a/crates/jp_llm/tests/fixtures/anthropic/test_opus_4_6_adaptive_thinking__conversation_stream.snap b/crates/jp_llm/tests/fixtures/anthropic/test_opus_4_6_adaptive_thinking__conversation_stream.snap index a6c4e147d..22ec16f4d 100644 --- a/crates/jp_llm/tests/fixtures/anthropic/test_opus_4_6_adaptive_thinking__conversation_stream.snap +++ b/crates/jp_llm/tests/fixtures/anthropic/test_opus_4_6_adaptive_thinking__conversation_stream.snap @@ -213,6 +213,10 @@ expression: v "api_key_env": "OPENROUTER_API_KEY", "app_name": "JP", "base_url": "https://openrouter.ai" + }, + "vllm": { + "api_key_env": "VLLM_API_KEY", + "base_url": "http://127.0.0.1:8000" } } }, diff --git a/crates/jp_llm/tests/fixtures/anthropic/test_opus_4_6_max_effort__conversation_stream.snap b/crates/jp_llm/tests/fixtures/anthropic/test_opus_4_6_max_effort__conversation_stream.snap index 46035d578..a230fb769 100644 --- a/crates/jp_llm/tests/fixtures/anthropic/test_opus_4_6_max_effort__conversation_stream.snap +++ b/crates/jp_llm/tests/fixtures/anthropic/test_opus_4_6_max_effort__conversation_stream.snap @@ -213,6 +213,10 @@ expression: v "api_key_env": "OPENROUTER_API_KEY", "app_name": "JP", "base_url": "https://openrouter.ai" + }, + "vllm": { + "api_key_env": "VLLM_API_KEY", + "base_url": "http://127.0.0.1:8000" } } }, diff --git a/crates/jp_llm/tests/fixtures/anthropic/test_redacted_thinking__conversation_stream.snap b/crates/jp_llm/tests/fixtures/anthropic/test_redacted_thinking__conversation_stream.snap index 6ae02dd08..69200fc15 100644 --- a/crates/jp_llm/tests/fixtures/anthropic/test_redacted_thinking__conversation_stream.snap +++ b/crates/jp_llm/tests/fixtures/anthropic/test_redacted_thinking__conversation_stream.snap @@ -210,6 +210,10 @@ expression: v "api_key_env": "OPENROUTER_API_KEY", "app_name": "JP", "base_url": "https://openrouter.ai" + }, + "vllm": { + "api_key_env": "VLLM_API_KEY", + "base_url": "http://127.0.0.1:8000" } } }, diff --git a/crates/jp_llm/tests/fixtures/anthropic/test_request_chaining__conversation_stream.snap b/crates/jp_llm/tests/fixtures/anthropic/test_request_chaining__conversation_stream.snap index e83d646e5..fe47d7b8c 100644 --- a/crates/jp_llm/tests/fixtures/anthropic/test_request_chaining__conversation_stream.snap +++ b/crates/jp_llm/tests/fixtures/anthropic/test_request_chaining__conversation_stream.snap @@ -215,6 +215,10 @@ expression: v "api_key_env": "OPENROUTER_API_KEY", "app_name": "JP", "base_url": "https://openrouter.ai" + }, + "vllm": { + "api_key_env": "VLLM_API_KEY", + "base_url": "http://127.0.0.1:8000" } } }, diff --git a/crates/jp_llm/tests/fixtures/anthropic/test_structured_output__conversation_stream.snap b/crates/jp_llm/tests/fixtures/anthropic/test_structured_output__conversation_stream.snap index cf595bbaa..45f711b4d 100644 --- a/crates/jp_llm/tests/fixtures/anthropic/test_structured_output__conversation_stream.snap +++ b/crates/jp_llm/tests/fixtures/anthropic/test_structured_output__conversation_stream.snap @@ -210,6 +210,10 @@ expression: v "api_key_env": "OPENROUTER_API_KEY", "app_name": "JP", "base_url": "https://openrouter.ai" + }, + "vllm": { + "api_key_env": "VLLM_API_KEY", + "base_url": "http://127.0.0.1:8000" } } }, diff --git a/crates/jp_llm/tests/fixtures/anthropic/test_tool_call_auto__conversation_stream.snap b/crates/jp_llm/tests/fixtures/anthropic/test_tool_call_auto__conversation_stream.snap index fad5af547..5bae98a5d 100644 --- a/crates/jp_llm/tests/fixtures/anthropic/test_tool_call_auto__conversation_stream.snap +++ b/crates/jp_llm/tests/fixtures/anthropic/test_tool_call_auto__conversation_stream.snap @@ -210,6 +210,10 @@ expression: v "api_key_env": "OPENROUTER_API_KEY", "app_name": "JP", "base_url": "https://openrouter.ai" + }, + "vllm": { + "api_key_env": "VLLM_API_KEY", + "base_url": "http://127.0.0.1:8000" } } }, diff --git a/crates/jp_llm/tests/fixtures/anthropic/test_tool_call_function__conversation_stream.snap b/crates/jp_llm/tests/fixtures/anthropic/test_tool_call_function__conversation_stream.snap index 2f4cee447..8f4a411e2 100644 --- a/crates/jp_llm/tests/fixtures/anthropic/test_tool_call_function__conversation_stream.snap +++ b/crates/jp_llm/tests/fixtures/anthropic/test_tool_call_function__conversation_stream.snap @@ -210,6 +210,10 @@ expression: v "api_key_env": "OPENROUTER_API_KEY", "app_name": "JP", "base_url": "https://openrouter.ai" + }, + "vllm": { + "api_key_env": "VLLM_API_KEY", + "base_url": "http://127.0.0.1:8000" } } }, diff --git a/crates/jp_llm/tests/fixtures/anthropic/test_tool_call_reasoning__conversation_stream.snap b/crates/jp_llm/tests/fixtures/anthropic/test_tool_call_reasoning__conversation_stream.snap index ea573fa8c..53ce388b4 100644 --- a/crates/jp_llm/tests/fixtures/anthropic/test_tool_call_reasoning__conversation_stream.snap +++ b/crates/jp_llm/tests/fixtures/anthropic/test_tool_call_reasoning__conversation_stream.snap @@ -210,6 +210,10 @@ expression: v "api_key_env": "OPENROUTER_API_KEY", "app_name": "JP", "base_url": "https://openrouter.ai" + }, + "vllm": { + "api_key_env": "VLLM_API_KEY", + "base_url": "http://127.0.0.1:8000" } } }, diff --git a/crates/jp_llm/tests/fixtures/anthropic/test_tool_call_required_no_reasoning__conversation_stream.snap b/crates/jp_llm/tests/fixtures/anthropic/test_tool_call_required_no_reasoning__conversation_stream.snap index 528e0ff14..c9e76c59e 100644 --- a/crates/jp_llm/tests/fixtures/anthropic/test_tool_call_required_no_reasoning__conversation_stream.snap +++ b/crates/jp_llm/tests/fixtures/anthropic/test_tool_call_required_no_reasoning__conversation_stream.snap @@ -210,6 +210,10 @@ expression: v "api_key_env": "OPENROUTER_API_KEY", "app_name": "JP", "base_url": "https://openrouter.ai" + }, + "vllm": { + "api_key_env": "VLLM_API_KEY", + "base_url": "http://127.0.0.1:8000" } } }, diff --git a/crates/jp_llm/tests/fixtures/anthropic/test_tool_call_required_reasoning__conversation_stream.snap b/crates/jp_llm/tests/fixtures/anthropic/test_tool_call_required_reasoning__conversation_stream.snap index 4a6933967..580983dbd 100644 --- a/crates/jp_llm/tests/fixtures/anthropic/test_tool_call_required_reasoning__conversation_stream.snap +++ b/crates/jp_llm/tests/fixtures/anthropic/test_tool_call_required_reasoning__conversation_stream.snap @@ -210,6 +210,10 @@ expression: v "api_key_env": "OPENROUTER_API_KEY", "app_name": "JP", "base_url": "https://openrouter.ai" + }, + "vllm": { + "api_key_env": "VLLM_API_KEY", + "base_url": "http://127.0.0.1:8000" } } }, diff --git a/crates/jp_llm/tests/fixtures/anthropic/test_tool_call_stream__conversation_stream.snap b/crates/jp_llm/tests/fixtures/anthropic/test_tool_call_stream__conversation_stream.snap index d661c6079..09333da10 100644 --- a/crates/jp_llm/tests/fixtures/anthropic/test_tool_call_stream__conversation_stream.snap +++ b/crates/jp_llm/tests/fixtures/anthropic/test_tool_call_stream__conversation_stream.snap @@ -210,6 +210,10 @@ expression: v "api_key_env": "OPENROUTER_API_KEY", "app_name": "JP", "base_url": "https://openrouter.ai" + }, + "vllm": { + "api_key_env": "VLLM_API_KEY", + "base_url": "http://127.0.0.1:8000" } } }, diff --git a/crates/jp_llm/tests/fixtures/cerebras/test_chat_completion_stream__conversation_stream.snap b/crates/jp_llm/tests/fixtures/cerebras/test_chat_completion_stream__conversation_stream.snap index 984b905f0..6c504e464 100644 --- a/crates/jp_llm/tests/fixtures/cerebras/test_chat_completion_stream__conversation_stream.snap +++ b/crates/jp_llm/tests/fixtures/cerebras/test_chat_completion_stream__conversation_stream.snap @@ -213,6 +213,10 @@ expression: v "api_key_env": "OPENROUTER_API_KEY", "app_name": "JP", "base_url": "https://openrouter.ai" + }, + "vllm": { + "api_key_env": "VLLM_API_KEY", + "base_url": "http://127.0.0.1:8000" } } }, diff --git a/crates/jp_llm/tests/fixtures/cerebras/test_multi_turn_conversation__conversation_stream.snap b/crates/jp_llm/tests/fixtures/cerebras/test_multi_turn_conversation__conversation_stream.snap index 00c729a77..7fb69c2c0 100644 --- a/crates/jp_llm/tests/fixtures/cerebras/test_multi_turn_conversation__conversation_stream.snap +++ b/crates/jp_llm/tests/fixtures/cerebras/test_multi_turn_conversation__conversation_stream.snap @@ -213,6 +213,10 @@ expression: v "api_key_env": "OPENROUTER_API_KEY", "app_name": "JP", "base_url": "https://openrouter.ai" + }, + "vllm": { + "api_key_env": "VLLM_API_KEY", + "base_url": "http://127.0.0.1:8000" } } }, diff --git a/crates/jp_llm/tests/fixtures/cerebras/test_structured_output__conversation_stream.snap b/crates/jp_llm/tests/fixtures/cerebras/test_structured_output__conversation_stream.snap index 8a80fa4fb..34cea3ee4 100644 --- a/crates/jp_llm/tests/fixtures/cerebras/test_structured_output__conversation_stream.snap +++ b/crates/jp_llm/tests/fixtures/cerebras/test_structured_output__conversation_stream.snap @@ -210,6 +210,10 @@ expression: v "api_key_env": "OPENROUTER_API_KEY", "app_name": "JP", "base_url": "https://openrouter.ai" + }, + "vllm": { + "api_key_env": "VLLM_API_KEY", + "base_url": "http://127.0.0.1:8000" } } }, diff --git a/crates/jp_llm/tests/fixtures/cerebras/test_tool_call_auto__conversation_stream.snap b/crates/jp_llm/tests/fixtures/cerebras/test_tool_call_auto__conversation_stream.snap index 8998f4085..7e0dec139 100644 --- a/crates/jp_llm/tests/fixtures/cerebras/test_tool_call_auto__conversation_stream.snap +++ b/crates/jp_llm/tests/fixtures/cerebras/test_tool_call_auto__conversation_stream.snap @@ -210,6 +210,10 @@ expression: v "api_key_env": "OPENROUTER_API_KEY", "app_name": "JP", "base_url": "https://openrouter.ai" + }, + "vllm": { + "api_key_env": "VLLM_API_KEY", + "base_url": "http://127.0.0.1:8000" } } }, diff --git a/crates/jp_llm/tests/fixtures/cerebras/test_tool_call_function__conversation_stream.snap b/crates/jp_llm/tests/fixtures/cerebras/test_tool_call_function__conversation_stream.snap index cfb9ff551..653c3985c 100644 --- a/crates/jp_llm/tests/fixtures/cerebras/test_tool_call_function__conversation_stream.snap +++ b/crates/jp_llm/tests/fixtures/cerebras/test_tool_call_function__conversation_stream.snap @@ -210,6 +210,10 @@ expression: v "api_key_env": "OPENROUTER_API_KEY", "app_name": "JP", "base_url": "https://openrouter.ai" + }, + "vllm": { + "api_key_env": "VLLM_API_KEY", + "base_url": "http://127.0.0.1:8000" } } }, diff --git a/crates/jp_llm/tests/fixtures/cerebras/test_tool_call_reasoning__conversation_stream.snap b/crates/jp_llm/tests/fixtures/cerebras/test_tool_call_reasoning__conversation_stream.snap index f353e1d03..3e9afb5be 100644 --- a/crates/jp_llm/tests/fixtures/cerebras/test_tool_call_reasoning__conversation_stream.snap +++ b/crates/jp_llm/tests/fixtures/cerebras/test_tool_call_reasoning__conversation_stream.snap @@ -210,6 +210,10 @@ expression: v "api_key_env": "OPENROUTER_API_KEY", "app_name": "JP", "base_url": "https://openrouter.ai" + }, + "vllm": { + "api_key_env": "VLLM_API_KEY", + "base_url": "http://127.0.0.1:8000" } } }, diff --git a/crates/jp_llm/tests/fixtures/cerebras/test_tool_call_required_no_reasoning__conversation_stream.snap b/crates/jp_llm/tests/fixtures/cerebras/test_tool_call_required_no_reasoning__conversation_stream.snap index c551949bc..ab72c56e5 100644 --- a/crates/jp_llm/tests/fixtures/cerebras/test_tool_call_required_no_reasoning__conversation_stream.snap +++ b/crates/jp_llm/tests/fixtures/cerebras/test_tool_call_required_no_reasoning__conversation_stream.snap @@ -210,6 +210,10 @@ expression: v "api_key_env": "OPENROUTER_API_KEY", "app_name": "JP", "base_url": "https://openrouter.ai" + }, + "vllm": { + "api_key_env": "VLLM_API_KEY", + "base_url": "http://127.0.0.1:8000" } } }, diff --git a/crates/jp_llm/tests/fixtures/cerebras/test_tool_call_required_reasoning__conversation_stream.snap b/crates/jp_llm/tests/fixtures/cerebras/test_tool_call_required_reasoning__conversation_stream.snap index c09a3150f..f4aac9940 100644 --- a/crates/jp_llm/tests/fixtures/cerebras/test_tool_call_required_reasoning__conversation_stream.snap +++ b/crates/jp_llm/tests/fixtures/cerebras/test_tool_call_required_reasoning__conversation_stream.snap @@ -210,6 +210,10 @@ expression: v "api_key_env": "OPENROUTER_API_KEY", "app_name": "JP", "base_url": "https://openrouter.ai" + }, + "vllm": { + "api_key_env": "VLLM_API_KEY", + "base_url": "http://127.0.0.1:8000" } } }, diff --git a/crates/jp_llm/tests/fixtures/cerebras/test_tool_call_stream__conversation_stream.snap b/crates/jp_llm/tests/fixtures/cerebras/test_tool_call_stream__conversation_stream.snap index 97b0eec5e..ed6302d5d 100644 --- a/crates/jp_llm/tests/fixtures/cerebras/test_tool_call_stream__conversation_stream.snap +++ b/crates/jp_llm/tests/fixtures/cerebras/test_tool_call_stream__conversation_stream.snap @@ -210,6 +210,10 @@ expression: v "api_key_env": "OPENROUTER_API_KEY", "app_name": "JP", "base_url": "https://openrouter.ai" + }, + "vllm": { + "api_key_env": "VLLM_API_KEY", + "base_url": "http://127.0.0.1:8000" } } }, diff --git a/crates/jp_llm/tests/fixtures/cerebras/test_unknown_model_auto_omits_effort__conversation_stream.snap b/crates/jp_llm/tests/fixtures/cerebras/test_unknown_model_auto_omits_effort__conversation_stream.snap index d63d8f9c6..143466b81 100644 --- a/crates/jp_llm/tests/fixtures/cerebras/test_unknown_model_auto_omits_effort__conversation_stream.snap +++ b/crates/jp_llm/tests/fixtures/cerebras/test_unknown_model_auto_omits_effort__conversation_stream.snap @@ -210,6 +210,10 @@ expression: v "api_key_env": "OPENROUTER_API_KEY", "app_name": "JP", "base_url": "https://openrouter.ai" + }, + "vllm": { + "api_key_env": "VLLM_API_KEY", + "base_url": "http://127.0.0.1:8000" } } }, diff --git a/crates/jp_llm/tests/fixtures/cerebras/test_unknown_model_off_sends_none__conversation_stream.snap b/crates/jp_llm/tests/fixtures/cerebras/test_unknown_model_off_sends_none__conversation_stream.snap index 851bd4c54..de165d026 100644 --- a/crates/jp_llm/tests/fixtures/cerebras/test_unknown_model_off_sends_none__conversation_stream.snap +++ b/crates/jp_llm/tests/fixtures/cerebras/test_unknown_model_off_sends_none__conversation_stream.snap @@ -210,6 +210,10 @@ expression: v "api_key_env": "OPENROUTER_API_KEY", "app_name": "JP", "base_url": "https://openrouter.ai" + }, + "vllm": { + "api_key_env": "VLLM_API_KEY", + "base_url": "http://127.0.0.1:8000" } } }, diff --git a/crates/jp_llm/tests/fixtures/google/test_chat_completion_stream__conversation_stream.snap b/crates/jp_llm/tests/fixtures/google/test_chat_completion_stream__conversation_stream.snap index 30075fc2a..f09f2fa8d 100644 --- a/crates/jp_llm/tests/fixtures/google/test_chat_completion_stream__conversation_stream.snap +++ b/crates/jp_llm/tests/fixtures/google/test_chat_completion_stream__conversation_stream.snap @@ -213,6 +213,10 @@ expression: v "api_key_env": "OPENROUTER_API_KEY", "app_name": "JP", "base_url": "https://openrouter.ai" + }, + "vllm": { + "api_key_env": "VLLM_API_KEY", + "base_url": "http://127.0.0.1:8000" } } }, diff --git a/crates/jp_llm/tests/fixtures/google/test_gemini_3_reasoning__conversation_stream.snap b/crates/jp_llm/tests/fixtures/google/test_gemini_3_reasoning__conversation_stream.snap index 50cb779e3..9660d4f03 100644 --- a/crates/jp_llm/tests/fixtures/google/test_gemini_3_reasoning__conversation_stream.snap +++ b/crates/jp_llm/tests/fixtures/google/test_gemini_3_reasoning__conversation_stream.snap @@ -213,6 +213,10 @@ expression: v "api_key_env": "OPENROUTER_API_KEY", "app_name": "JP", "base_url": "https://openrouter.ai" + }, + "vllm": { + "api_key_env": "VLLM_API_KEY", + "base_url": "http://127.0.0.1:8000" } } }, diff --git a/crates/jp_llm/tests/fixtures/google/test_image_attachment__conversation_stream.snap b/crates/jp_llm/tests/fixtures/google/test_image_attachment__conversation_stream.snap index 5989403ee..3a4a1777a 100644 --- a/crates/jp_llm/tests/fixtures/google/test_image_attachment__conversation_stream.snap +++ b/crates/jp_llm/tests/fixtures/google/test_image_attachment__conversation_stream.snap @@ -210,6 +210,10 @@ expression: v "api_key_env": "OPENROUTER_API_KEY", "app_name": "JP", "base_url": "https://openrouter.ai" + }, + "vllm": { + "api_key_env": "VLLM_API_KEY", + "base_url": "http://127.0.0.1:8000" } } }, diff --git a/crates/jp_llm/tests/fixtures/google/test_multi_turn_conversation__conversation_stream.snap b/crates/jp_llm/tests/fixtures/google/test_multi_turn_conversation__conversation_stream.snap index 263e862d6..2b7c251f5 100644 --- a/crates/jp_llm/tests/fixtures/google/test_multi_turn_conversation__conversation_stream.snap +++ b/crates/jp_llm/tests/fixtures/google/test_multi_turn_conversation__conversation_stream.snap @@ -213,6 +213,10 @@ expression: v "api_key_env": "OPENROUTER_API_KEY", "app_name": "JP", "base_url": "https://openrouter.ai" + }, + "vllm": { + "api_key_env": "VLLM_API_KEY", + "base_url": "http://127.0.0.1:8000" } } }, diff --git a/crates/jp_llm/tests/fixtures/google/test_structured_output__conversation_stream.snap b/crates/jp_llm/tests/fixtures/google/test_structured_output__conversation_stream.snap index ce781587a..8dd367b5f 100644 --- a/crates/jp_llm/tests/fixtures/google/test_structured_output__conversation_stream.snap +++ b/crates/jp_llm/tests/fixtures/google/test_structured_output__conversation_stream.snap @@ -210,6 +210,10 @@ expression: v "api_key_env": "OPENROUTER_API_KEY", "app_name": "JP", "base_url": "https://openrouter.ai" + }, + "vllm": { + "api_key_env": "VLLM_API_KEY", + "base_url": "http://127.0.0.1:8000" } } }, diff --git a/crates/jp_llm/tests/fixtures/google/test_tool_call_auto__conversation_stream.snap b/crates/jp_llm/tests/fixtures/google/test_tool_call_auto__conversation_stream.snap index f10013c8e..78831f7c7 100644 --- a/crates/jp_llm/tests/fixtures/google/test_tool_call_auto__conversation_stream.snap +++ b/crates/jp_llm/tests/fixtures/google/test_tool_call_auto__conversation_stream.snap @@ -210,6 +210,10 @@ expression: v "api_key_env": "OPENROUTER_API_KEY", "app_name": "JP", "base_url": "https://openrouter.ai" + }, + "vllm": { + "api_key_env": "VLLM_API_KEY", + "base_url": "http://127.0.0.1:8000" } } }, diff --git a/crates/jp_llm/tests/fixtures/google/test_tool_call_function__conversation_stream.snap b/crates/jp_llm/tests/fixtures/google/test_tool_call_function__conversation_stream.snap index 94ff60d15..61e7c7659 100644 --- a/crates/jp_llm/tests/fixtures/google/test_tool_call_function__conversation_stream.snap +++ b/crates/jp_llm/tests/fixtures/google/test_tool_call_function__conversation_stream.snap @@ -210,6 +210,10 @@ expression: v "api_key_env": "OPENROUTER_API_KEY", "app_name": "JP", "base_url": "https://openrouter.ai" + }, + "vllm": { + "api_key_env": "VLLM_API_KEY", + "base_url": "http://127.0.0.1:8000" } } }, diff --git a/crates/jp_llm/tests/fixtures/google/test_tool_call_reasoning__conversation_stream.snap b/crates/jp_llm/tests/fixtures/google/test_tool_call_reasoning__conversation_stream.snap index 6f13d1f36..c9a211461 100644 --- a/crates/jp_llm/tests/fixtures/google/test_tool_call_reasoning__conversation_stream.snap +++ b/crates/jp_llm/tests/fixtures/google/test_tool_call_reasoning__conversation_stream.snap @@ -210,6 +210,10 @@ expression: v "api_key_env": "OPENROUTER_API_KEY", "app_name": "JP", "base_url": "https://openrouter.ai" + }, + "vllm": { + "api_key_env": "VLLM_API_KEY", + "base_url": "http://127.0.0.1:8000" } } }, diff --git a/crates/jp_llm/tests/fixtures/google/test_tool_call_required_no_reasoning__conversation_stream.snap b/crates/jp_llm/tests/fixtures/google/test_tool_call_required_no_reasoning__conversation_stream.snap index 3b7638e2f..48abc5384 100644 --- a/crates/jp_llm/tests/fixtures/google/test_tool_call_required_no_reasoning__conversation_stream.snap +++ b/crates/jp_llm/tests/fixtures/google/test_tool_call_required_no_reasoning__conversation_stream.snap @@ -210,6 +210,10 @@ expression: v "api_key_env": "OPENROUTER_API_KEY", "app_name": "JP", "base_url": "https://openrouter.ai" + }, + "vllm": { + "api_key_env": "VLLM_API_KEY", + "base_url": "http://127.0.0.1:8000" } } }, diff --git a/crates/jp_llm/tests/fixtures/google/test_tool_call_required_reasoning__conversation_stream.snap b/crates/jp_llm/tests/fixtures/google/test_tool_call_required_reasoning__conversation_stream.snap index c912b441f..5f86ca5f7 100644 --- a/crates/jp_llm/tests/fixtures/google/test_tool_call_required_reasoning__conversation_stream.snap +++ b/crates/jp_llm/tests/fixtures/google/test_tool_call_required_reasoning__conversation_stream.snap @@ -210,6 +210,10 @@ expression: v "api_key_env": "OPENROUTER_API_KEY", "app_name": "JP", "base_url": "https://openrouter.ai" + }, + "vllm": { + "api_key_env": "VLLM_API_KEY", + "base_url": "http://127.0.0.1:8000" } } }, diff --git a/crates/jp_llm/tests/fixtures/google/test_tool_call_stream__conversation_stream.snap b/crates/jp_llm/tests/fixtures/google/test_tool_call_stream__conversation_stream.snap index 406e242d0..e355d40fd 100644 --- a/crates/jp_llm/tests/fixtures/google/test_tool_call_stream__conversation_stream.snap +++ b/crates/jp_llm/tests/fixtures/google/test_tool_call_stream__conversation_stream.snap @@ -210,6 +210,10 @@ expression: v "api_key_env": "OPENROUTER_API_KEY", "app_name": "JP", "base_url": "https://openrouter.ai" + }, + "vllm": { + "api_key_env": "VLLM_API_KEY", + "base_url": "http://127.0.0.1:8000" } } }, diff --git a/crates/jp_llm/tests/fixtures/google/test_unknown_model_inferred_thinking_level__conversation_stream.snap b/crates/jp_llm/tests/fixtures/google/test_unknown_model_inferred_thinking_level__conversation_stream.snap index 108727e96..aacc44f92 100644 --- a/crates/jp_llm/tests/fixtures/google/test_unknown_model_inferred_thinking_level__conversation_stream.snap +++ b/crates/jp_llm/tests/fixtures/google/test_unknown_model_inferred_thinking_level__conversation_stream.snap @@ -213,6 +213,10 @@ expression: v "api_key_env": "OPENROUTER_API_KEY", "app_name": "JP", "base_url": "https://openrouter.ai" + }, + "vllm": { + "api_key_env": "VLLM_API_KEY", + "base_url": "http://127.0.0.1:8000" } } }, diff --git a/crates/jp_llm/tests/fixtures/llamacpp/test_chat_completion_stream__conversation_stream.snap b/crates/jp_llm/tests/fixtures/llamacpp/test_chat_completion_stream__conversation_stream.snap index d68339ac8..be4170a28 100644 --- a/crates/jp_llm/tests/fixtures/llamacpp/test_chat_completion_stream__conversation_stream.snap +++ b/crates/jp_llm/tests/fixtures/llamacpp/test_chat_completion_stream__conversation_stream.snap @@ -213,6 +213,10 @@ expression: v "api_key_env": "OPENROUTER_API_KEY", "app_name": "JP", "base_url": "https://openrouter.ai" + }, + "vllm": { + "api_key_env": "VLLM_API_KEY", + "base_url": "http://127.0.0.1:8000" } } }, diff --git a/crates/jp_llm/tests/fixtures/llamacpp/test_image_attachment__conversation_stream.snap b/crates/jp_llm/tests/fixtures/llamacpp/test_image_attachment__conversation_stream.snap index 30a93654a..79d49ae82 100644 --- a/crates/jp_llm/tests/fixtures/llamacpp/test_image_attachment__conversation_stream.snap +++ b/crates/jp_llm/tests/fixtures/llamacpp/test_image_attachment__conversation_stream.snap @@ -210,6 +210,10 @@ expression: v "api_key_env": "OPENROUTER_API_KEY", "app_name": "JP", "base_url": "https://openrouter.ai" + }, + "vllm": { + "api_key_env": "VLLM_API_KEY", + "base_url": "http://127.0.0.1:8000" } } }, diff --git a/crates/jp_llm/tests/fixtures/llamacpp/test_multi_turn_conversation__conversation_stream.snap b/crates/jp_llm/tests/fixtures/llamacpp/test_multi_turn_conversation__conversation_stream.snap index 8086ed732..ca4ad1ce9 100644 --- a/crates/jp_llm/tests/fixtures/llamacpp/test_multi_turn_conversation__conversation_stream.snap +++ b/crates/jp_llm/tests/fixtures/llamacpp/test_multi_turn_conversation__conversation_stream.snap @@ -213,6 +213,10 @@ expression: v "api_key_env": "OPENROUTER_API_KEY", "app_name": "JP", "base_url": "https://openrouter.ai" + }, + "vllm": { + "api_key_env": "VLLM_API_KEY", + "base_url": "http://127.0.0.1:8000" } } }, diff --git a/crates/jp_llm/tests/fixtures/llamacpp/test_structured_output__conversation_stream.snap b/crates/jp_llm/tests/fixtures/llamacpp/test_structured_output__conversation_stream.snap index 48abf484d..a7556eea0 100644 --- a/crates/jp_llm/tests/fixtures/llamacpp/test_structured_output__conversation_stream.snap +++ b/crates/jp_llm/tests/fixtures/llamacpp/test_structured_output__conversation_stream.snap @@ -210,6 +210,10 @@ expression: v "api_key_env": "OPENROUTER_API_KEY", "app_name": "JP", "base_url": "https://openrouter.ai" + }, + "vllm": { + "api_key_env": "VLLM_API_KEY", + "base_url": "http://127.0.0.1:8000" } } }, diff --git a/crates/jp_llm/tests/fixtures/llamacpp/test_tool_call_auto__conversation_stream.snap b/crates/jp_llm/tests/fixtures/llamacpp/test_tool_call_auto__conversation_stream.snap index ddf37eaa1..1f1c5fc41 100644 --- a/crates/jp_llm/tests/fixtures/llamacpp/test_tool_call_auto__conversation_stream.snap +++ b/crates/jp_llm/tests/fixtures/llamacpp/test_tool_call_auto__conversation_stream.snap @@ -210,6 +210,10 @@ expression: v "api_key_env": "OPENROUTER_API_KEY", "app_name": "JP", "base_url": "https://openrouter.ai" + }, + "vllm": { + "api_key_env": "VLLM_API_KEY", + "base_url": "http://127.0.0.1:8000" } } }, diff --git a/crates/jp_llm/tests/fixtures/llamacpp/test_tool_call_function__conversation_stream.snap b/crates/jp_llm/tests/fixtures/llamacpp/test_tool_call_function__conversation_stream.snap index 499818698..17916e0ea 100644 --- a/crates/jp_llm/tests/fixtures/llamacpp/test_tool_call_function__conversation_stream.snap +++ b/crates/jp_llm/tests/fixtures/llamacpp/test_tool_call_function__conversation_stream.snap @@ -210,6 +210,10 @@ expression: v "api_key_env": "OPENROUTER_API_KEY", "app_name": "JP", "base_url": "https://openrouter.ai" + }, + "vllm": { + "api_key_env": "VLLM_API_KEY", + "base_url": "http://127.0.0.1:8000" } } }, diff --git a/crates/jp_llm/tests/fixtures/llamacpp/test_tool_call_reasoning__conversation_stream.snap b/crates/jp_llm/tests/fixtures/llamacpp/test_tool_call_reasoning__conversation_stream.snap index a455a4cd1..9f803e3db 100644 --- a/crates/jp_llm/tests/fixtures/llamacpp/test_tool_call_reasoning__conversation_stream.snap +++ b/crates/jp_llm/tests/fixtures/llamacpp/test_tool_call_reasoning__conversation_stream.snap @@ -210,6 +210,10 @@ expression: v "api_key_env": "OPENROUTER_API_KEY", "app_name": "JP", "base_url": "https://openrouter.ai" + }, + "vllm": { + "api_key_env": "VLLM_API_KEY", + "base_url": "http://127.0.0.1:8000" } } }, diff --git a/crates/jp_llm/tests/fixtures/llamacpp/test_tool_call_required_no_reasoning__conversation_stream.snap b/crates/jp_llm/tests/fixtures/llamacpp/test_tool_call_required_no_reasoning__conversation_stream.snap index d985dfc24..a367fc471 100644 --- a/crates/jp_llm/tests/fixtures/llamacpp/test_tool_call_required_no_reasoning__conversation_stream.snap +++ b/crates/jp_llm/tests/fixtures/llamacpp/test_tool_call_required_no_reasoning__conversation_stream.snap @@ -210,6 +210,10 @@ expression: v "api_key_env": "OPENROUTER_API_KEY", "app_name": "JP", "base_url": "https://openrouter.ai" + }, + "vllm": { + "api_key_env": "VLLM_API_KEY", + "base_url": "http://127.0.0.1:8000" } } }, diff --git a/crates/jp_llm/tests/fixtures/llamacpp/test_tool_call_required_reasoning__conversation_stream.snap b/crates/jp_llm/tests/fixtures/llamacpp/test_tool_call_required_reasoning__conversation_stream.snap index 812991126..ce5d2f16f 100644 --- a/crates/jp_llm/tests/fixtures/llamacpp/test_tool_call_required_reasoning__conversation_stream.snap +++ b/crates/jp_llm/tests/fixtures/llamacpp/test_tool_call_required_reasoning__conversation_stream.snap @@ -210,6 +210,10 @@ expression: v "api_key_env": "OPENROUTER_API_KEY", "app_name": "JP", "base_url": "https://openrouter.ai" + }, + "vllm": { + "api_key_env": "VLLM_API_KEY", + "base_url": "http://127.0.0.1:8000" } } }, diff --git a/crates/jp_llm/tests/fixtures/llamacpp/test_tool_call_stream__conversation_stream.snap b/crates/jp_llm/tests/fixtures/llamacpp/test_tool_call_stream__conversation_stream.snap index 219e762a8..3b911d2e6 100644 --- a/crates/jp_llm/tests/fixtures/llamacpp/test_tool_call_stream__conversation_stream.snap +++ b/crates/jp_llm/tests/fixtures/llamacpp/test_tool_call_stream__conversation_stream.snap @@ -210,6 +210,10 @@ expression: v "api_key_env": "OPENROUTER_API_KEY", "app_name": "JP", "base_url": "https://openrouter.ai" + }, + "vllm": { + "api_key_env": "VLLM_API_KEY", + "base_url": "http://127.0.0.1:8000" } } }, diff --git a/crates/jp_llm/tests/fixtures/ollama/test_chat_completion_stream__conversation_stream.snap b/crates/jp_llm/tests/fixtures/ollama/test_chat_completion_stream__conversation_stream.snap index 9ac67ca17..f48756816 100644 --- a/crates/jp_llm/tests/fixtures/ollama/test_chat_completion_stream__conversation_stream.snap +++ b/crates/jp_llm/tests/fixtures/ollama/test_chat_completion_stream__conversation_stream.snap @@ -213,6 +213,10 @@ expression: v "api_key_env": "OPENROUTER_API_KEY", "app_name": "JP", "base_url": "https://openrouter.ai" + }, + "vllm": { + "api_key_env": "VLLM_API_KEY", + "base_url": "http://127.0.0.1:8000" } } }, diff --git a/crates/jp_llm/tests/fixtures/ollama/test_image_attachment__conversation_stream.snap b/crates/jp_llm/tests/fixtures/ollama/test_image_attachment__conversation_stream.snap index ad9fc79f4..4744c7fe6 100644 --- a/crates/jp_llm/tests/fixtures/ollama/test_image_attachment__conversation_stream.snap +++ b/crates/jp_llm/tests/fixtures/ollama/test_image_attachment__conversation_stream.snap @@ -210,6 +210,10 @@ expression: v "api_key_env": "OPENROUTER_API_KEY", "app_name": "JP", "base_url": "https://openrouter.ai" + }, + "vllm": { + "api_key_env": "VLLM_API_KEY", + "base_url": "http://127.0.0.1:8000" } } }, diff --git a/crates/jp_llm/tests/fixtures/ollama/test_multi_turn_conversation__conversation_stream.snap b/crates/jp_llm/tests/fixtures/ollama/test_multi_turn_conversation__conversation_stream.snap index 6d4b3d56e..a8daa1145 100644 --- a/crates/jp_llm/tests/fixtures/ollama/test_multi_turn_conversation__conversation_stream.snap +++ b/crates/jp_llm/tests/fixtures/ollama/test_multi_turn_conversation__conversation_stream.snap @@ -213,6 +213,10 @@ expression: v "api_key_env": "OPENROUTER_API_KEY", "app_name": "JP", "base_url": "https://openrouter.ai" + }, + "vllm": { + "api_key_env": "VLLM_API_KEY", + "base_url": "http://127.0.0.1:8000" } } }, diff --git a/crates/jp_llm/tests/fixtures/ollama/test_structured_output__conversation_stream.snap b/crates/jp_llm/tests/fixtures/ollama/test_structured_output__conversation_stream.snap index dad24b32a..ef6542f53 100644 --- a/crates/jp_llm/tests/fixtures/ollama/test_structured_output__conversation_stream.snap +++ b/crates/jp_llm/tests/fixtures/ollama/test_structured_output__conversation_stream.snap @@ -210,6 +210,10 @@ expression: v "api_key_env": "OPENROUTER_API_KEY", "app_name": "JP", "base_url": "https://openrouter.ai" + }, + "vllm": { + "api_key_env": "VLLM_API_KEY", + "base_url": "http://127.0.0.1:8000" } } }, diff --git a/crates/jp_llm/tests/fixtures/ollama/test_tool_call_auto__conversation_stream.snap b/crates/jp_llm/tests/fixtures/ollama/test_tool_call_auto__conversation_stream.snap index 5aa8c54b5..c6d023f00 100644 --- a/crates/jp_llm/tests/fixtures/ollama/test_tool_call_auto__conversation_stream.snap +++ b/crates/jp_llm/tests/fixtures/ollama/test_tool_call_auto__conversation_stream.snap @@ -210,6 +210,10 @@ expression: v "api_key_env": "OPENROUTER_API_KEY", "app_name": "JP", "base_url": "https://openrouter.ai" + }, + "vllm": { + "api_key_env": "VLLM_API_KEY", + "base_url": "http://127.0.0.1:8000" } } }, diff --git a/crates/jp_llm/tests/fixtures/ollama/test_tool_call_function__conversation_stream.snap b/crates/jp_llm/tests/fixtures/ollama/test_tool_call_function__conversation_stream.snap index e49abe95b..6f03c0b17 100644 --- a/crates/jp_llm/tests/fixtures/ollama/test_tool_call_function__conversation_stream.snap +++ b/crates/jp_llm/tests/fixtures/ollama/test_tool_call_function__conversation_stream.snap @@ -210,6 +210,10 @@ expression: v "api_key_env": "OPENROUTER_API_KEY", "app_name": "JP", "base_url": "https://openrouter.ai" + }, + "vllm": { + "api_key_env": "VLLM_API_KEY", + "base_url": "http://127.0.0.1:8000" } } }, diff --git a/crates/jp_llm/tests/fixtures/ollama/test_tool_call_reasoning__conversation_stream.snap b/crates/jp_llm/tests/fixtures/ollama/test_tool_call_reasoning__conversation_stream.snap index 1b700e129..669df3858 100644 --- a/crates/jp_llm/tests/fixtures/ollama/test_tool_call_reasoning__conversation_stream.snap +++ b/crates/jp_llm/tests/fixtures/ollama/test_tool_call_reasoning__conversation_stream.snap @@ -210,6 +210,10 @@ expression: v "api_key_env": "OPENROUTER_API_KEY", "app_name": "JP", "base_url": "https://openrouter.ai" + }, + "vllm": { + "api_key_env": "VLLM_API_KEY", + "base_url": "http://127.0.0.1:8000" } } }, diff --git a/crates/jp_llm/tests/fixtures/ollama/test_tool_call_required_no_reasoning__conversation_stream.snap b/crates/jp_llm/tests/fixtures/ollama/test_tool_call_required_no_reasoning__conversation_stream.snap index e948cbd1e..105ce03a2 100644 --- a/crates/jp_llm/tests/fixtures/ollama/test_tool_call_required_no_reasoning__conversation_stream.snap +++ b/crates/jp_llm/tests/fixtures/ollama/test_tool_call_required_no_reasoning__conversation_stream.snap @@ -210,6 +210,10 @@ expression: v "api_key_env": "OPENROUTER_API_KEY", "app_name": "JP", "base_url": "https://openrouter.ai" + }, + "vllm": { + "api_key_env": "VLLM_API_KEY", + "base_url": "http://127.0.0.1:8000" } } }, diff --git a/crates/jp_llm/tests/fixtures/ollama/test_tool_call_required_reasoning__conversation_stream.snap b/crates/jp_llm/tests/fixtures/ollama/test_tool_call_required_reasoning__conversation_stream.snap index aedad076a..c7c2812ae 100644 --- a/crates/jp_llm/tests/fixtures/ollama/test_tool_call_required_reasoning__conversation_stream.snap +++ b/crates/jp_llm/tests/fixtures/ollama/test_tool_call_required_reasoning__conversation_stream.snap @@ -210,6 +210,10 @@ expression: v "api_key_env": "OPENROUTER_API_KEY", "app_name": "JP", "base_url": "https://openrouter.ai" + }, + "vllm": { + "api_key_env": "VLLM_API_KEY", + "base_url": "http://127.0.0.1:8000" } } }, diff --git a/crates/jp_llm/tests/fixtures/ollama/test_tool_call_stream__conversation_stream.snap b/crates/jp_llm/tests/fixtures/ollama/test_tool_call_stream__conversation_stream.snap index 5450fc417..1b0852326 100644 --- a/crates/jp_llm/tests/fixtures/ollama/test_tool_call_stream__conversation_stream.snap +++ b/crates/jp_llm/tests/fixtures/ollama/test_tool_call_stream__conversation_stream.snap @@ -210,6 +210,10 @@ expression: v "api_key_env": "OPENROUTER_API_KEY", "app_name": "JP", "base_url": "https://openrouter.ai" + }, + "vllm": { + "api_key_env": "VLLM_API_KEY", + "base_url": "http://127.0.0.1:8000" } } }, diff --git a/crates/jp_llm/tests/fixtures/openai/test_chat_completion_stream__conversation_stream.snap b/crates/jp_llm/tests/fixtures/openai/test_chat_completion_stream__conversation_stream.snap index 8d3121034..713f86885 100644 --- a/crates/jp_llm/tests/fixtures/openai/test_chat_completion_stream__conversation_stream.snap +++ b/crates/jp_llm/tests/fixtures/openai/test_chat_completion_stream__conversation_stream.snap @@ -213,6 +213,10 @@ expression: v "api_key_env": "OPENROUTER_API_KEY", "app_name": "JP", "base_url": "https://openrouter.ai" + }, + "vllm": { + "api_key_env": "VLLM_API_KEY", + "base_url": "http://127.0.0.1:8000" } } }, diff --git a/crates/jp_llm/tests/fixtures/openai/test_gpt_5_6_cache_off_sends_explicit_optout__conversation_stream.snap b/crates/jp_llm/tests/fixtures/openai/test_gpt_5_6_cache_off_sends_explicit_optout__conversation_stream.snap index 9139eb7d7..ad85a8dc8 100644 --- a/crates/jp_llm/tests/fixtures/openai/test_gpt_5_6_cache_off_sends_explicit_optout__conversation_stream.snap +++ b/crates/jp_llm/tests/fixtures/openai/test_gpt_5_6_cache_off_sends_explicit_optout__conversation_stream.snap @@ -213,6 +213,10 @@ expression: v "api_key_env": "OPENROUTER_API_KEY", "app_name": "JP", "base_url": "https://openrouter.ai" + }, + "vllm": { + "api_key_env": "VLLM_API_KEY", + "base_url": "http://127.0.0.1:8000" } } }, diff --git a/crates/jp_llm/tests/fixtures/openai/test_gpt_5_6_pro_reasoning_and_explicit_caching__conversation_stream.snap b/crates/jp_llm/tests/fixtures/openai/test_gpt_5_6_pro_reasoning_and_explicit_caching__conversation_stream.snap index 6e11a4c19..110e56ce3 100644 --- a/crates/jp_llm/tests/fixtures/openai/test_gpt_5_6_pro_reasoning_and_explicit_caching__conversation_stream.snap +++ b/crates/jp_llm/tests/fixtures/openai/test_gpt_5_6_pro_reasoning_and_explicit_caching__conversation_stream.snap @@ -215,6 +215,10 @@ expression: v "api_key_env": "OPENROUTER_API_KEY", "app_name": "JP", "base_url": "https://openrouter.ai" + }, + "vllm": { + "api_key_env": "VLLM_API_KEY", + "base_url": "http://127.0.0.1:8000" } } }, diff --git a/crates/jp_llm/tests/fixtures/openai/test_gpt_5_6_prompt_cache_read_after_write__conversation_stream.snap b/crates/jp_llm/tests/fixtures/openai/test_gpt_5_6_prompt_cache_read_after_write__conversation_stream.snap index 6099e32e3..f4b68861e 100644 --- a/crates/jp_llm/tests/fixtures/openai/test_gpt_5_6_prompt_cache_read_after_write__conversation_stream.snap +++ b/crates/jp_llm/tests/fixtures/openai/test_gpt_5_6_prompt_cache_read_after_write__conversation_stream.snap @@ -210,6 +210,10 @@ expression: v "api_key_env": "OPENROUTER_API_KEY", "app_name": "JP", "base_url": "https://openrouter.ai" + }, + "vllm": { + "api_key_env": "VLLM_API_KEY", + "base_url": "http://127.0.0.1:8000" } } }, diff --git a/crates/jp_llm/tests/fixtures/openai/test_image_attachment__conversation_stream.snap b/crates/jp_llm/tests/fixtures/openai/test_image_attachment__conversation_stream.snap index e7c167ddc..5f28596e5 100644 --- a/crates/jp_llm/tests/fixtures/openai/test_image_attachment__conversation_stream.snap +++ b/crates/jp_llm/tests/fixtures/openai/test_image_attachment__conversation_stream.snap @@ -210,6 +210,10 @@ expression: v "api_key_env": "OPENROUTER_API_KEY", "app_name": "JP", "base_url": "https://openrouter.ai" + }, + "vllm": { + "api_key_env": "VLLM_API_KEY", + "base_url": "http://127.0.0.1:8000" } } }, diff --git a/crates/jp_llm/tests/fixtures/openai/test_multi_turn_conversation__conversation_stream.snap b/crates/jp_llm/tests/fixtures/openai/test_multi_turn_conversation__conversation_stream.snap index 1e370d6c5..53cac156a 100644 --- a/crates/jp_llm/tests/fixtures/openai/test_multi_turn_conversation__conversation_stream.snap +++ b/crates/jp_llm/tests/fixtures/openai/test_multi_turn_conversation__conversation_stream.snap @@ -213,6 +213,10 @@ expression: v "api_key_env": "OPENROUTER_API_KEY", "app_name": "JP", "base_url": "https://openrouter.ai" + }, + "vllm": { + "api_key_env": "VLLM_API_KEY", + "base_url": "http://127.0.0.1:8000" } } }, diff --git a/crates/jp_llm/tests/fixtures/openai/test_reasoning_history_replayed_to_reasoning_unsupported_model__conversation_stream.snap b/crates/jp_llm/tests/fixtures/openai/test_reasoning_history_replayed_to_reasoning_unsupported_model__conversation_stream.snap index ef2906b43..7272cdaa4 100644 --- a/crates/jp_llm/tests/fixtures/openai/test_reasoning_history_replayed_to_reasoning_unsupported_model__conversation_stream.snap +++ b/crates/jp_llm/tests/fixtures/openai/test_reasoning_history_replayed_to_reasoning_unsupported_model__conversation_stream.snap @@ -210,6 +210,10 @@ expression: v "api_key_env": "OPENROUTER_API_KEY", "app_name": "JP", "base_url": "https://openrouter.ai" + }, + "vllm": { + "api_key_env": "VLLM_API_KEY", + "base_url": "http://127.0.0.1:8000" } } }, diff --git a/crates/jp_llm/tests/fixtures/openai/test_structured_output__conversation_stream.snap b/crates/jp_llm/tests/fixtures/openai/test_structured_output__conversation_stream.snap index 60fcd7ec0..860ccecb3 100644 --- a/crates/jp_llm/tests/fixtures/openai/test_structured_output__conversation_stream.snap +++ b/crates/jp_llm/tests/fixtures/openai/test_structured_output__conversation_stream.snap @@ -210,6 +210,10 @@ expression: v "api_key_env": "OPENROUTER_API_KEY", "app_name": "JP", "base_url": "https://openrouter.ai" + }, + "vllm": { + "api_key_env": "VLLM_API_KEY", + "base_url": "http://127.0.0.1:8000" } } }, diff --git a/crates/jp_llm/tests/fixtures/openai/test_tool_call_auto__conversation_stream.snap b/crates/jp_llm/tests/fixtures/openai/test_tool_call_auto__conversation_stream.snap index 9c87ef9b9..881119faa 100644 --- a/crates/jp_llm/tests/fixtures/openai/test_tool_call_auto__conversation_stream.snap +++ b/crates/jp_llm/tests/fixtures/openai/test_tool_call_auto__conversation_stream.snap @@ -210,6 +210,10 @@ expression: v "api_key_env": "OPENROUTER_API_KEY", "app_name": "JP", "base_url": "https://openrouter.ai" + }, + "vllm": { + "api_key_env": "VLLM_API_KEY", + "base_url": "http://127.0.0.1:8000" } } }, diff --git a/crates/jp_llm/tests/fixtures/openai/test_tool_call_function__conversation_stream.snap b/crates/jp_llm/tests/fixtures/openai/test_tool_call_function__conversation_stream.snap index bdae0c9f5..c90a4bfbb 100644 --- a/crates/jp_llm/tests/fixtures/openai/test_tool_call_function__conversation_stream.snap +++ b/crates/jp_llm/tests/fixtures/openai/test_tool_call_function__conversation_stream.snap @@ -210,6 +210,10 @@ expression: v "api_key_env": "OPENROUTER_API_KEY", "app_name": "JP", "base_url": "https://openrouter.ai" + }, + "vllm": { + "api_key_env": "VLLM_API_KEY", + "base_url": "http://127.0.0.1:8000" } } }, diff --git a/crates/jp_llm/tests/fixtures/openai/test_tool_call_reasoning__conversation_stream.snap b/crates/jp_llm/tests/fixtures/openai/test_tool_call_reasoning__conversation_stream.snap index 982d27ad1..3e02ccafa 100644 --- a/crates/jp_llm/tests/fixtures/openai/test_tool_call_reasoning__conversation_stream.snap +++ b/crates/jp_llm/tests/fixtures/openai/test_tool_call_reasoning__conversation_stream.snap @@ -210,6 +210,10 @@ expression: v "api_key_env": "OPENROUTER_API_KEY", "app_name": "JP", "base_url": "https://openrouter.ai" + }, + "vllm": { + "api_key_env": "VLLM_API_KEY", + "base_url": "http://127.0.0.1:8000" } } }, diff --git a/crates/jp_llm/tests/fixtures/openai/test_tool_call_required_no_reasoning__conversation_stream.snap b/crates/jp_llm/tests/fixtures/openai/test_tool_call_required_no_reasoning__conversation_stream.snap index 03b0d6585..ee7934f0f 100644 --- a/crates/jp_llm/tests/fixtures/openai/test_tool_call_required_no_reasoning__conversation_stream.snap +++ b/crates/jp_llm/tests/fixtures/openai/test_tool_call_required_no_reasoning__conversation_stream.snap @@ -210,6 +210,10 @@ expression: v "api_key_env": "OPENROUTER_API_KEY", "app_name": "JP", "base_url": "https://openrouter.ai" + }, + "vllm": { + "api_key_env": "VLLM_API_KEY", + "base_url": "http://127.0.0.1:8000" } } }, diff --git a/crates/jp_llm/tests/fixtures/openai/test_tool_call_required_reasoning__conversation_stream.snap b/crates/jp_llm/tests/fixtures/openai/test_tool_call_required_reasoning__conversation_stream.snap index e57be6444..6527cc0a4 100644 --- a/crates/jp_llm/tests/fixtures/openai/test_tool_call_required_reasoning__conversation_stream.snap +++ b/crates/jp_llm/tests/fixtures/openai/test_tool_call_required_reasoning__conversation_stream.snap @@ -210,6 +210,10 @@ expression: v "api_key_env": "OPENROUTER_API_KEY", "app_name": "JP", "base_url": "https://openrouter.ai" + }, + "vllm": { + "api_key_env": "VLLM_API_KEY", + "base_url": "http://127.0.0.1:8000" } } }, diff --git a/crates/jp_llm/tests/fixtures/openai/test_tool_call_stream__conversation_stream.snap b/crates/jp_llm/tests/fixtures/openai/test_tool_call_stream__conversation_stream.snap index c677257a7..9323cd5d2 100644 --- a/crates/jp_llm/tests/fixtures/openai/test_tool_call_stream__conversation_stream.snap +++ b/crates/jp_llm/tests/fixtures/openai/test_tool_call_stream__conversation_stream.snap @@ -210,6 +210,10 @@ expression: v "api_key_env": "OPENROUTER_API_KEY", "app_name": "JP", "base_url": "https://openrouter.ai" + }, + "vllm": { + "api_key_env": "VLLM_API_KEY", + "base_url": "http://127.0.0.1:8000" } } }, diff --git a/crates/jp_llm/tests/fixtures/openrouter/anthropic_test_sub_provider_event_metadata__conversation_stream.snap b/crates/jp_llm/tests/fixtures/openrouter/anthropic_test_sub_provider_event_metadata__conversation_stream.snap index 7d08d1cda..ed17621a8 100644 --- a/crates/jp_llm/tests/fixtures/openrouter/anthropic_test_sub_provider_event_metadata__conversation_stream.snap +++ b/crates/jp_llm/tests/fixtures/openrouter/anthropic_test_sub_provider_event_metadata__conversation_stream.snap @@ -213,6 +213,10 @@ expression: v "api_key_env": "OPENROUTER_API_KEY", "app_name": "JP", "base_url": "https://openrouter.ai" + }, + "vllm": { + "api_key_env": "VLLM_API_KEY", + "base_url": "http://127.0.0.1:8000" } } }, diff --git a/crates/jp_llm/tests/fixtures/openrouter/google_test_sub_provider_event_metadata__conversation_stream.snap b/crates/jp_llm/tests/fixtures/openrouter/google_test_sub_provider_event_metadata__conversation_stream.snap index bba998777..39160f53e 100644 --- a/crates/jp_llm/tests/fixtures/openrouter/google_test_sub_provider_event_metadata__conversation_stream.snap +++ b/crates/jp_llm/tests/fixtures/openrouter/google_test_sub_provider_event_metadata__conversation_stream.snap @@ -213,6 +213,10 @@ expression: v "api_key_env": "OPENROUTER_API_KEY", "app_name": "JP", "base_url": "https://openrouter.ai" + }, + "vllm": { + "api_key_env": "VLLM_API_KEY", + "base_url": "http://127.0.0.1:8000" } } }, diff --git a/crates/jp_llm/tests/fixtures/openrouter/minimax_test_sub_provider_event_metadata__conversation_stream.snap b/crates/jp_llm/tests/fixtures/openrouter/minimax_test_sub_provider_event_metadata__conversation_stream.snap index 70e71c438..8623ea407 100644 --- a/crates/jp_llm/tests/fixtures/openrouter/minimax_test_sub_provider_event_metadata__conversation_stream.snap +++ b/crates/jp_llm/tests/fixtures/openrouter/minimax_test_sub_provider_event_metadata__conversation_stream.snap @@ -213,6 +213,10 @@ expression: v "api_key_env": "OPENROUTER_API_KEY", "app_name": "JP", "base_url": "https://openrouter.ai" + }, + "vllm": { + "api_key_env": "VLLM_API_KEY", + "base_url": "http://127.0.0.1:8000" } } }, diff --git a/crates/jp_llm/tests/fixtures/openrouter/test_anthropic_opus_5_parallel_tool_round_trip__conversation_stream.snap b/crates/jp_llm/tests/fixtures/openrouter/test_anthropic_opus_5_parallel_tool_round_trip__conversation_stream.snap index 5828e4f6d..5404ad3db 100644 --- a/crates/jp_llm/tests/fixtures/openrouter/test_anthropic_opus_5_parallel_tool_round_trip__conversation_stream.snap +++ b/crates/jp_llm/tests/fixtures/openrouter/test_anthropic_opus_5_parallel_tool_round_trip__conversation_stream.snap @@ -213,6 +213,10 @@ expression: v "api_key_env": "OPENROUTER_API_KEY", "app_name": "JP", "base_url": "https://openrouter.ai" + }, + "vllm": { + "api_key_env": "VLLM_API_KEY", + "base_url": "http://127.0.0.1:8000" } } }, diff --git a/crates/jp_llm/tests/fixtures/openrouter/test_chat_completion_stream__conversation_stream.snap b/crates/jp_llm/tests/fixtures/openrouter/test_chat_completion_stream__conversation_stream.snap index 37c971074..99341153e 100644 --- a/crates/jp_llm/tests/fixtures/openrouter/test_chat_completion_stream__conversation_stream.snap +++ b/crates/jp_llm/tests/fixtures/openrouter/test_chat_completion_stream__conversation_stream.snap @@ -213,6 +213,10 @@ expression: v "api_key_env": "OPENROUTER_API_KEY", "app_name": "JP", "base_url": "https://openrouter.ai" + }, + "vllm": { + "api_key_env": "VLLM_API_KEY", + "base_url": "http://127.0.0.1:8000" } } }, diff --git a/crates/jp_llm/tests/fixtures/openrouter/test_image_attachment__conversation_stream.snap b/crates/jp_llm/tests/fixtures/openrouter/test_image_attachment__conversation_stream.snap index ee4cb45d8..02317899b 100644 --- a/crates/jp_llm/tests/fixtures/openrouter/test_image_attachment__conversation_stream.snap +++ b/crates/jp_llm/tests/fixtures/openrouter/test_image_attachment__conversation_stream.snap @@ -210,6 +210,10 @@ expression: v "api_key_env": "OPENROUTER_API_KEY", "app_name": "JP", "base_url": "https://openrouter.ai" + }, + "vllm": { + "api_key_env": "VLLM_API_KEY", + "base_url": "http://127.0.0.1:8000" } } }, diff --git a/crates/jp_llm/tests/fixtures/openrouter/test_multi_turn_conversation__conversation_stream.snap b/crates/jp_llm/tests/fixtures/openrouter/test_multi_turn_conversation__conversation_stream.snap index 354cc27d3..c81b01cdb 100644 --- a/crates/jp_llm/tests/fixtures/openrouter/test_multi_turn_conversation__conversation_stream.snap +++ b/crates/jp_llm/tests/fixtures/openrouter/test_multi_turn_conversation__conversation_stream.snap @@ -213,6 +213,10 @@ expression: v "api_key_env": "OPENROUTER_API_KEY", "app_name": "JP", "base_url": "https://openrouter.ai" + }, + "vllm": { + "api_key_env": "VLLM_API_KEY", + "base_url": "http://127.0.0.1:8000" } } }, diff --git a/crates/jp_llm/tests/fixtures/openrouter/test_structured_output__conversation_stream.snap b/crates/jp_llm/tests/fixtures/openrouter/test_structured_output__conversation_stream.snap index 889fee0c8..58c27c393 100644 --- a/crates/jp_llm/tests/fixtures/openrouter/test_structured_output__conversation_stream.snap +++ b/crates/jp_llm/tests/fixtures/openrouter/test_structured_output__conversation_stream.snap @@ -210,6 +210,10 @@ expression: v "api_key_env": "OPENROUTER_API_KEY", "app_name": "JP", "base_url": "https://openrouter.ai" + }, + "vllm": { + "api_key_env": "VLLM_API_KEY", + "base_url": "http://127.0.0.1:8000" } } }, diff --git a/crates/jp_llm/tests/fixtures/openrouter/test_tool_call_auto__conversation_stream.snap b/crates/jp_llm/tests/fixtures/openrouter/test_tool_call_auto__conversation_stream.snap index e8ec1681a..1e6a691b6 100644 --- a/crates/jp_llm/tests/fixtures/openrouter/test_tool_call_auto__conversation_stream.snap +++ b/crates/jp_llm/tests/fixtures/openrouter/test_tool_call_auto__conversation_stream.snap @@ -210,6 +210,10 @@ expression: v "api_key_env": "OPENROUTER_API_KEY", "app_name": "JP", "base_url": "https://openrouter.ai" + }, + "vllm": { + "api_key_env": "VLLM_API_KEY", + "base_url": "http://127.0.0.1:8000" } } }, diff --git a/crates/jp_llm/tests/fixtures/openrouter/test_tool_call_function__conversation_stream.snap b/crates/jp_llm/tests/fixtures/openrouter/test_tool_call_function__conversation_stream.snap index d58498f40..f85621645 100644 --- a/crates/jp_llm/tests/fixtures/openrouter/test_tool_call_function__conversation_stream.snap +++ b/crates/jp_llm/tests/fixtures/openrouter/test_tool_call_function__conversation_stream.snap @@ -210,6 +210,10 @@ expression: v "api_key_env": "OPENROUTER_API_KEY", "app_name": "JP", "base_url": "https://openrouter.ai" + }, + "vllm": { + "api_key_env": "VLLM_API_KEY", + "base_url": "http://127.0.0.1:8000" } } }, diff --git a/crates/jp_llm/tests/fixtures/openrouter/test_tool_call_reasoning__conversation_stream.snap b/crates/jp_llm/tests/fixtures/openrouter/test_tool_call_reasoning__conversation_stream.snap index e84297dca..353a13ea2 100644 --- a/crates/jp_llm/tests/fixtures/openrouter/test_tool_call_reasoning__conversation_stream.snap +++ b/crates/jp_llm/tests/fixtures/openrouter/test_tool_call_reasoning__conversation_stream.snap @@ -210,6 +210,10 @@ expression: v "api_key_env": "OPENROUTER_API_KEY", "app_name": "JP", "base_url": "https://openrouter.ai" + }, + "vllm": { + "api_key_env": "VLLM_API_KEY", + "base_url": "http://127.0.0.1:8000" } } }, diff --git a/crates/jp_llm/tests/fixtures/openrouter/test_tool_call_required_no_reasoning__conversation_stream.snap b/crates/jp_llm/tests/fixtures/openrouter/test_tool_call_required_no_reasoning__conversation_stream.snap index 33d34571f..c45a12e60 100644 --- a/crates/jp_llm/tests/fixtures/openrouter/test_tool_call_required_no_reasoning__conversation_stream.snap +++ b/crates/jp_llm/tests/fixtures/openrouter/test_tool_call_required_no_reasoning__conversation_stream.snap @@ -210,6 +210,10 @@ expression: v "api_key_env": "OPENROUTER_API_KEY", "app_name": "JP", "base_url": "https://openrouter.ai" + }, + "vllm": { + "api_key_env": "VLLM_API_KEY", + "base_url": "http://127.0.0.1:8000" } } }, diff --git a/crates/jp_llm/tests/fixtures/openrouter/test_tool_call_required_reasoning__conversation_stream.snap b/crates/jp_llm/tests/fixtures/openrouter/test_tool_call_required_reasoning__conversation_stream.snap index e5926a447..999fa982e 100644 --- a/crates/jp_llm/tests/fixtures/openrouter/test_tool_call_required_reasoning__conversation_stream.snap +++ b/crates/jp_llm/tests/fixtures/openrouter/test_tool_call_required_reasoning__conversation_stream.snap @@ -210,6 +210,10 @@ expression: v "api_key_env": "OPENROUTER_API_KEY", "app_name": "JP", "base_url": "https://openrouter.ai" + }, + "vllm": { + "api_key_env": "VLLM_API_KEY", + "base_url": "http://127.0.0.1:8000" } } }, diff --git a/crates/jp_llm/tests/fixtures/openrouter/test_tool_call_stream__conversation_stream.snap b/crates/jp_llm/tests/fixtures/openrouter/test_tool_call_stream__conversation_stream.snap index 30041cbf3..67ab9cba5 100644 --- a/crates/jp_llm/tests/fixtures/openrouter/test_tool_call_stream__conversation_stream.snap +++ b/crates/jp_llm/tests/fixtures/openrouter/test_tool_call_stream__conversation_stream.snap @@ -210,6 +210,10 @@ expression: v "api_key_env": "OPENROUTER_API_KEY", "app_name": "JP", "base_url": "https://openrouter.ai" + }, + "vllm": { + "api_key_env": "VLLM_API_KEY", + "base_url": "http://127.0.0.1:8000" } } }, diff --git a/crates/jp_llm/tests/fixtures/openrouter/x-ai_test_sub_provider_event_metadata__conversation_stream.snap b/crates/jp_llm/tests/fixtures/openrouter/x-ai_test_sub_provider_event_metadata__conversation_stream.snap index 4eaebc373..012fbddca 100644 --- a/crates/jp_llm/tests/fixtures/openrouter/x-ai_test_sub_provider_event_metadata__conversation_stream.snap +++ b/crates/jp_llm/tests/fixtures/openrouter/x-ai_test_sub_provider_event_metadata__conversation_stream.snap @@ -213,6 +213,10 @@ expression: v "api_key_env": "OPENROUTER_API_KEY", "app_name": "JP", "base_url": "https://openrouter.ai" + }, + "vllm": { + "api_key_env": "VLLM_API_KEY", + "base_url": "http://127.0.0.1:8000" } } }, diff --git a/docs/features/tools.md b/docs/features/tools.md index 4a8371e76..7adfe598d 100644 --- a/docs/features/tools.md +++ b/docs/features/tools.md @@ -197,7 +197,7 @@ Describe the parameter in configuration when that happens. Providers accept different subsets of JSON Schema, and each one adapts the schema itself. -OpenAI, Google, Anthropic, Cerebras, OpenRouter, and llama.cpp all accept +OpenAI, Google, Anthropic, Cerebras, OpenRouter, llama.cpp, and vLLM all accept references and definitions. Ollama does not, so its schemas are expanded before the request is sent. It also ignores keywords outside a small set, keeping `type`, `description`, diff --git a/docs/ticket/0khs4b1-add-a-vllm-provider.md b/docs/ticket/0khs4b1-add-a-vllm-provider.md new file mode 100644 index 000000000..308803fff --- /dev/null +++ b/docs/ticket/0khs4b1-add-a-vllm-provider.md @@ -0,0 +1,76 @@ +# Add a vLLM provider + +- **Status**: Done +- **Kind**: Feature +- **Authors**: jp +- **Date**: 2026-09-16 +- **Label**: domain=llm +- **Label**: package=jp_config +- **Label**: package=jp_llm +- **Label**: type=feature + +vLLM serves `GET /v1/models` and `POST /v1/chat/completions` behind a Bearer +token, speaking the same Chat Completions dialect that +`jp_llm::provider::openai_compat` already parses for llama.cpp. +A vLLM provider is therefore mostly config plumbing plus a thin provider module +over the shared dialect code. + +## Config + +- `ProviderId::Vllm` in `jp_config::model::id`, with `as_str() == "vllm"`. +- `providers/llm/vllm.rs` holding `VllmConfig`: `api_key_env` defaulting to + `VLLM_API_KEY`, and `base_url` defaulting to `http://127.0.0.1:8000`. + The four trait impls (`AssignKeyValue`, `PartialConfigDelta`, `FillDefaults`, + `ToPartial`) follow `deepseek.rs`. +- A `vllm` field on `LlmProviderConfig`, wired into each of the four impls in + `providers/llm.rs`. +- The `jp_config` snapshots for config fields, schema shape, and partial + defaults all move; review them with `cargo insta`. + +## Provider + +- Hoist `to_system_messages`, `convert_events`, `convert_tools`, and + `convert_tool_choice` out of `llamacpp.rs` into `openai_compat.rs` as + `pub(crate)`, and have llamacpp call them there. + Behavior-preserving. +- `provider/vllm.rs` with `Vllm { client, base_url }`. + `TryFrom<&VllmConfig>` reads the key from the environment and sets the Bearer + header, as `cerebras.rs` does. +- `models()` maps `GET /v1/models` entries to `ModelDetails`, taking + `context_window` from the reported `max_model_len` and keeping the full id + (e.g. `Qwen/Qwen3-8B`) as the name. + `model_details()` returns `ModelDetails::empty()` for an unknown name. +- `create_request()` builds the chat body: model, messages, stream, temperature, + top_p, max_tokens, tools, tool_choice, `response_format` for a structured + schema, and `chat_template_kwargs.enable_thinking` from the reasoning setting. + No `reasoning_format` field — that one is llama.cpp-specific. + `chat_completion_stream()` posts it and parses the SSE stream with + `parse_chunk()`. +- `mod vllm`, the `get_provider()` and `build_request_value()` arms in + `provider.rs`, and the `Vllm` arms in `test.rs` for `base_url` and + `api_key_env`. +- `vllm_tests.rs` covering `create_request()` for a plain message, a tool call + round trip, a structured schema, and reasoning off, each against a static + expected JSON body. + +## Docs + +Name vLLM in the provider sentence in `docs/features/tools.md`. + +## Comments + +----- + +- **From**: jp +- **Date**: 2026-09-16T02:01:11Z + +Filed after the fact: the config, provider, and docs work described above is +implemented and staged. +Deviations from the original plan worth recording: + +- The plan also called for adding vLLM to a provider list in + `docs/configuration.md`. + That file has no provider list, so there is nothing to add there. +- The plan's dotfiles step (the `nebius` alias and the `[providers.llm.vllm]` + table in `agentic-shepherd/dotfiles/jp-user-config/user-config.toml`) lives in + another repository and is out of scope here. From 87b5f87f26c8316e17f068edd6df60f6b3d45bbe Mon Sep 17 00:00:00 2001 From: rgrant Date: Wed, 16 Sep 2026 18:19:25 +0000 Subject: [PATCH 2/2] fix(llm, vllm): Join system parts into one message for vLLM vLLM renders chat requests through the served model's own chat template, and several templates reject a system message that isn't the first message in the list. `create_request` now joins the system prompt, prompt sections, and attachment XML into a single leading `system` message instead of emitting them as separate messages via `to_system_messages`, keeping every served model reachable regardless of its template's constraints. Adds a regression test, `create_request_joins_system_parts_into_one_message`, covering the merged-message behavior, and updates the recorded provider snapshot fixtures to match the new request shape. Signed-off-by: rgrant --- crates/jp_llm/src/provider/vllm.rs | 15 ++++++---- crates/jp_llm/src/provider/vllm_tests.rs | 37 +++++++++++++++++++++++- 2 files changed, 46 insertions(+), 6 deletions(-) diff --git a/crates/jp_llm/src/provider/vllm.rs b/crates/jp_llm/src/provider/vllm.rs index c71a340e2..a1152fac0 100644 --- a/crates/jp_llm/src/provider/vllm.rs +++ b/crates/jp_llm/src/provider/vllm.rs @@ -22,10 +22,7 @@ use tracing::{debug, trace, warn}; use super::{ EventStream, ModelDetails, - openai_compat::{ - assemble_event_stream, convert_events, convert_tool_choice, convert_tools, - to_system_messages, - }, + openai_compat::{assemble_event_stream, convert_events, convert_tool_choice, convert_tools}, trace_to_tmpfile, }; use crate::{error::Error, provider::Provider, query::ChatQuery, stream::with_tool_call_keepalive}; @@ -150,7 +147,15 @@ fn create_request(model: &ModelDetails, query: ChatQuery) -> Result<(Value, bool system_parts.push(xml); } - let mut messages: Vec = to_system_messages(system_parts).collect(); + // vLLM renders the request through the served model's own chat template, + // and several of those templates reject a system message that isn't the + // first message. Joining the parts keeps every served model reachable + // regardless of its template. + let mut messages: Vec = if system_parts.is_empty() { + vec![] + } else { + vec![json!({ "role": "system", "content": system_parts.join("\n\n") })] + }; // Prepend binary image attachments as a user message with image_url // content blocks (OpenAI chat completions format). diff --git a/crates/jp_llm/src/provider/vllm_tests.rs b/crates/jp_llm/src/provider/vllm_tests.rs index 12c390b6b..dd0095aa0 100644 --- a/crates/jp_llm/src/provider/vllm_tests.rs +++ b/crates/jp_llm/src/provider/vllm_tests.rs @@ -1,4 +1,7 @@ -use jp_config::{assistant::tool_choice::ToolChoice, model::parameters::PartialReasoningConfig}; +use jp_config::{ + assistant::{sections::SectionConfig, tool_choice::ToolChoice}, + model::parameters::PartialReasoningConfig, +}; use jp_conversation::{ ConversationEvent, ConversationStream, event::{ChatRequest, ChatResponse, ToolCallRequest, ToolCallResponse}, @@ -67,6 +70,38 @@ fn create_request_plain_message() { ); } +/// Regression: vLLM renders the request through the served model's own chat +/// template, and several of those templates reject a system message that isn't +/// the first message. +/// The prompt, its sections, and the attachment XML must therefore arrive as a +/// single system message. +#[test] +fn create_request_joins_system_parts_into_one_message() { + let query = ChatQuery { + thread: Thread { + system_prompt: Some("You are JP.".to_owned()), + sections: vec![ + SectionConfig::default().with_content("Rule 1."), + SectionConfig::default().with_content("Rule 2."), + ], + attachments: vec![], + events: ConversationStream::new_test().with_turn("test"), + }, + tools: vec![], + tool_choice: ToolChoice::Auto, + }; + + let (body, _) = create_request(&qwen_details(), query).unwrap(); + + assert_eq!( + body["messages"], + json!([ + { "role": "system", "content": "You are JP.\n\nRule 1.\n\nRule 2." }, + { "role": "user", "content": "test" }, + ]) + ); +} + /// Whether the model thinks at all is the chat template's decision, driven by /// `enable_thinking`. #[test]