Skip to main content

vtcode_llm/providers/
poolside.rs

1use serde_json::Value;
2use vtcode_config::constants::{env_vars, models, urls};
3
4use super::openai_compat::{OpenAiCompatCore, OpenAiCompatSpec, impl_openai_compat_provider};
5
6pub struct PoolsideSpec;
7
8fn no_reasoning(_message: &Value, _choice: &Value) -> Option<String> {
9    None
10}
11
12impl OpenAiCompatSpec for PoolsideSpec {
13    const NAME: &'static str = "Poolside";
14    const KEY: &'static str = "poolside";
15    const API_KEY_ENV: &'static str = "POOLSIDE_API_KEY";
16    const DEFAULT_MODEL: &'static str = models::poolside::DEFAULT_MODEL;
17    const DEFAULT_BASE_URL: &'static str = urls::POOLSIDE_API_BASE;
18    const BASE_URL_ENV: Option<&'static str> = Some(env_vars::POOLSIDE_BASE_URL);
19    const LISTED_MODELS: &'static [&'static str] = models::poolside::SUPPORTED_MODELS;
20    const VALIDATION_ALLOWLIST: Option<&'static [&'static str]> = Some(models::poolside::SUPPORTED_MODELS);
21
22    const SUPPRESS_SAMPLING_WHEN_REASONING: bool = false;
23    const STREAM_OPTIONS_INCLUDE_USAGE: bool = true;
24    const INCLUDE_USER_ID: bool = true;
25    const STREAM_REASONING_FIELDS: &'static [&'static str] = &[];
26    const DELTA_ORDER: super::shared::OpenAiDeltaOrder = super::shared::OpenAiDeltaOrder::ContentFirst;
27    // Poolside responses never carry reasoning text; the explicit no-op
28    // extractor also disables the default reasoning_content fallback.
29    const RESPONSE_REASONING_EXTRACTOR: Option<super::openai_compat::ReasoningExtractor> = Some(no_reasoning);
30
31    fn resolve_api_key(api_key: Option<String>) -> String {
32        api_key
33            .or_else(|| std::env::var(Self::API_KEY_ENV).ok().filter(|key| !key.trim().is_empty()))
34            .unwrap_or_default()
35    }
36
37    fn response_cache_metrics(core: &OpenAiCompatCore<Self>) -> bool {
38        core.prompt_cache_enabled
39    }
40
41    fn stream_cache_metrics(_core: &OpenAiCompatCore<Self>) -> bool {
42        true
43    }
44}
45
46impl_openai_compat_provider!(PoolsideProvider, PoolsideSpec, {
47    fn supports_streaming(&self) -> bool {
48        true
49    }
50
51    fn supports_structured_output(&self, _model: &str) -> bool {
52        true
53    }
54
55    fn supports_reasoning(&self, _model: &str) -> bool {
56        true
57    }
58
59    fn effective_context_size(&self, model: &str) -> usize {
60        crate::provider::catalog_context_window("poolside", model, 131_072)
61    }
62});
63
64#[cfg(test)]
65mod tests {
66    use super::*;
67    use crate::provider::{LLMRequest, Message, ToolChoice};
68    use std::sync::Arc;
69    use vtcode_config::types::ReasoningEffortLevel;
70
71    fn provider() -> PoolsideProvider {
72        PoolsideProvider::from_config(
73            Some("test-key".to_string()),
74            Some("malibu-latest".to_string()),
75            Some("https://example.test/v1".to_string()),
76            None,
77            None,
78            None,
79            None,
80        )
81    }
82
83    fn base_request() -> LLMRequest {
84        LLMRequest {
85            messages: vec![Message::user("hello".to_string())].into(),
86            system_prompt: Some(Arc::from("system guidance")),
87            model: "malibu-latest".to_string(),
88            max_tokens: Some(512),
89            temperature: Some(0.5),
90            top_p: Some(0.25),
91            stream: true,
92            tool_choice: Some(ToolChoice::Auto),
93            metadata: Some(serde_json::json!({"user_id": "user-42"})),
94            ..Default::default()
95        }
96    }
97
98    #[test]
99    fn golden_payload_basic_shape() {
100        let payload = provider().core.convert_request(&base_request()).unwrap();
101
102        assert_eq!(payload["model"], "malibu-latest");
103        let messages = payload["messages"].as_array().unwrap();
104        assert_eq!(messages.len(), 2);
105        assert_eq!(messages[0]["role"], "system");
106        assert_eq!(messages[0]["content"], "system guidance");
107        assert_eq!(messages[1]["role"], "user");
108        assert_eq!(messages[1]["content"], "hello");
109        assert_eq!(payload["max_tokens"], 512);
110        assert_eq!(payload["temperature"], 0.5);
111        assert_eq!(payload["top_p"], 0.25);
112        assert_eq!(payload["stream"], true);
113        assert_eq!(payload["stream_options"]["include_usage"], true);
114        assert_eq!(payload["tool_choice"], "auto");
115        assert_eq!(payload["user_id"], "user-42");
116    }
117
118    #[test]
119    fn golden_payload_reasoning_does_not_suppress_sampling() {
120        let mut request = base_request();
121        request.reasoning_effort = Some(ReasoningEffortLevel::High);
122        let payload = provider().core.convert_request(&request).unwrap();
123        assert_eq!(payload["temperature"], 0.5);
124        assert_eq!(payload["top_p"], 0.25);
125        assert!(payload.get("reasoning_effort").is_none());
126    }
127
128    #[test]
129    fn golden_payload_omits_empty_system_prompt() {
130        let mut request = base_request();
131        request.system_prompt = Some(Arc::from("   "));
132        request.stream = false;
133        let payload = provider().core.convert_request(&request).unwrap();
134        let messages = payload["messages"].as_array().unwrap();
135        assert_eq!(messages.len(), 1);
136        assert_eq!(messages[0]["role"], "user");
137        assert!(payload.get("stream").is_none());
138        assert!(payload.get("stream_options").is_none());
139    }
140}