Skip to main content

pi/providers/
gitlab.rs

1//! GitLab Duo provider implementation.
2//!
3//! GitLab Duo uses the `/api/v4/chat/completions` endpoint with a proprietary
4//! request/response format (NOT OpenAI-compatible).
5//!
6//! Authentication is via a GitLab Personal Access Token (PAT) or OAuth token
7//! passed as `Authorization: Bearer <token>`.
8//!
9//! Self-hosted GitLab instances are supported via a configurable base URL
10//! (defaults to `https://gitlab.com`).
11//!
12//! bd-3uqg.3.5
13
14use crate::error::{Error, Result};
15use crate::http::client::Client;
16use crate::model::{
17    AssistantMessage, ContentBlock, Message, StopReason, StreamEvent, TextContent, Usage,
18    UserContent,
19};
20use crate::models::CompatConfig;
21use crate::provider::{Context, Provider, StreamOptions};
22use async_trait::async_trait;
23use futures::Stream;
24use futures::stream;
25use serde::{Deserialize, Serialize};
26use std::pin::Pin;
27
28// ── Constants ────────────────────────────────────────────────────
29
30/// Default GitLab instance base URL.
31const DEFAULT_GITLAB_BASE: &str = "https://gitlab.com";
32
33/// Chat completions API path.
34const CHAT_API_PATH: &str = "/api/v4/chat/completions";
35
36// ── Request types ────────────────────────────────────────────────
37
38/// GitLab Duo Chat request body.
39#[derive(Debug, Serialize)]
40pub struct GitLabChatRequest {
41    /// The user's question/prompt.
42    content: String,
43    /// Additional context items (files, MRs, issues).
44    #[serde(skip_serializing_if = "Vec::is_empty")]
45    additional_context: Vec<GitLabContextItem>,
46}
47
48/// A context item attached to a GitLab Chat request.
49#[derive(Debug, Serialize)]
50struct GitLabContextItem {
51    /// Category: "file", "merge_request", "issue", "snippet".
52    category: String,
53    /// Identifier for the context item.
54    id: String,
55    /// Content of the context item.
56    content: String,
57}
58
59/// GitLab Chat response (plain text or JSON wrapper).
60#[derive(Debug, Deserialize)]
61struct GitLabChatResponse {
62    /// The generated response text.
63    #[serde(default)]
64    response: String,
65    /// Alternative: some GitLab versions return content directly.
66    #[serde(default)]
67    content: String,
68}
69
70// ── Provider ─────────────────────────────────────────────────────
71
72/// GitLab Duo provider.
73pub struct GitLabProvider {
74    /// HTTP client.
75    client: Client,
76    /// Model identifier.
77    model: String,
78    /// GitLab instance base URL (e.g., `https://gitlab.com` or `https://gitlab.example.com`).
79    base_url: String,
80    /// Provider name for event attribution.
81    provider_name: String,
82    /// Compatibility overrides (unused for GitLab but kept for interface consistency).
83    #[allow(dead_code)]
84    compat: Option<CompatConfig>,
85}
86
87impl GitLabProvider {
88    /// Create a new GitLab Duo provider.
89    pub fn new(model: impl Into<String>) -> Self {
90        Self {
91            client: Client::new(),
92            model: model.into(),
93            base_url: DEFAULT_GITLAB_BASE.to_string(),
94            provider_name: "gitlab".to_string(),
95            compat: None,
96        }
97    }
98
99    /// Set the GitLab instance base URL (for self-hosted).
100    #[must_use]
101    pub fn with_base_url(mut self, url: impl Into<String>) -> Self {
102        let url = url.into();
103        let trimmed = url.trim();
104        if !trimmed.is_empty() {
105            self.base_url = trimmed.to_string();
106        }
107        self
108    }
109
110    /// Set the provider name for event attribution.
111    #[must_use]
112    pub fn with_provider_name(mut self, name: impl Into<String>) -> Self {
113        self.provider_name = name.into();
114        self
115    }
116
117    /// Attach compatibility overrides.
118    #[must_use]
119    pub fn with_compat(mut self, compat: Option<CompatConfig>) -> Self {
120        self.compat = compat;
121        self
122    }
123
124    /// Inject a custom HTTP client (for testing / VCR).
125    #[must_use]
126    pub fn with_client(mut self, client: Client) -> Self {
127        self.client = client;
128        self
129    }
130
131    /// Build the chat completions URL.
132    fn chat_url(&self) -> String {
133        let base = self.base_url.trim_end_matches('/');
134        format!("{base}{CHAT_API_PATH}")
135    }
136
137    /// Build a GitLab Chat request from the agent context.
138    pub fn build_request(context: &Context<'_>) -> GitLabChatRequest {
139        // Extract the last user message as the primary content.
140        let mut content = String::new();
141        let mut additional_context = Vec::new();
142
143        // Walk messages to build context. The last user message becomes `content`,
144        // earlier messages become additional context for continuity.
145        for (i, msg) in context.messages.iter().enumerate().rev() {
146            match msg {
147                Message::User(user_msg) => {
148                    if content.is_empty() {
149                        // Last user message → primary content.
150                        match &user_msg.content {
151                            UserContent::Text(text) => content.clone_from(text),
152                            UserContent::Blocks(blocks) => {
153                                // Concatenate text parts.
154                                let texts: Vec<&str> = blocks
155                                    .iter()
156                                    .filter_map(|p| {
157                                        if let ContentBlock::Text(t) = p {
158                                            Some(t.text.as_str())
159                                        } else {
160                                            None
161                                        }
162                                    })
163                                    .collect();
164                                content = texts.join("\n");
165                            }
166                        }
167                    } else {
168                        // Earlier user message → context.
169                        let text = match &user_msg.content {
170                            UserContent::Text(t) => t.clone(),
171                            UserContent::Blocks(blocks) => blocks
172                                .iter()
173                                .filter_map(|p| {
174                                    if let ContentBlock::Text(t) = p {
175                                        Some(t.text.as_str())
176                                    } else {
177                                        None
178                                    }
179                                })
180                                .collect::<Vec<_>>()
181                                .join("\n"),
182                        };
183                        additional_context.push(GitLabContextItem {
184                            category: "file".to_string(),
185                            id: format!("message-{i}"),
186                            content: format!("[User]: {text}"),
187                        });
188                    }
189                }
190                Message::Assistant(asst_msg) => {
191                    // Include prior assistant responses as context.
192                    let text: String = asst_msg
193                        .content
194                        .iter()
195                        .filter_map(|c| {
196                            if let ContentBlock::Text(t) = c {
197                                Some(t.text.as_str())
198                            } else {
199                                None
200                            }
201                        })
202                        .collect::<Vec<_>>()
203                        .join("\n");
204                    if !text.is_empty() {
205                        additional_context.push(GitLabContextItem {
206                            category: "file".to_string(),
207                            id: format!("message-{i}"),
208                            content: format!("[Assistant]: {text}"),
209                        });
210                    }
211                }
212                _ => {}
213            }
214        }
215
216        // Include system prompt as context if present.
217        if let Some(system) = &context.system_prompt {
218            additional_context.push(GitLabContextItem {
219                category: "file".to_string(),
220                id: "system-prompt".to_string(),
221                content: format!("[System]: {system}"),
222            });
223        }
224
225        // Reverse additional_context to chronological order.
226        additional_context.reverse();
227
228        // Fallback if no user message found.
229        if content.is_empty() {
230            content = "Hello".to_string();
231        }
232
233        GitLabChatRequest {
234            content,
235            additional_context,
236        }
237    }
238}
239
240#[async_trait]
241impl Provider for GitLabProvider {
242    fn name(&self) -> &str {
243        &self.provider_name
244    }
245
246    fn api(&self) -> &'static str {
247        "gitlab-chat"
248    }
249
250    fn model_id(&self) -> &str {
251        &self.model
252    }
253
254    async fn stream(
255        &self,
256        context: &Context<'_>,
257        options: &StreamOptions,
258    ) -> Result<Pin<Box<dyn Stream<Item = Result<StreamEvent>> + Send>>> {
259        let request_body = Self::build_request(context);
260        let url = self.chat_url();
261
262        let api_key = options.api_key.as_deref().ok_or_else(|| {
263            Error::auth(
264                "GitLab API token is required. Set GITLAB_TOKEN or GITLAB_API_KEY environment variable.",
265            )
266        })?;
267
268        let body_bytes = serde_json::to_vec(&request_body)
269            .map_err(|e| Error::provider("gitlab", format!("Failed to serialize request: {e}")))?;
270
271        let mut request = self
272            .client
273            .post(&url)
274            .header("Authorization", format!("Bearer {api_key}"))
275            .header("Content-Type", "application/json")
276            .header("Accept", "application/json");
277
278        // Add any custom headers from options.
279        for (key, value) in &options.headers {
280            request = request.header(key, value);
281        }
282
283        let response = Box::pin(request.body(body_bytes).send())
284            .await
285            .map_err(|e| Error::provider("gitlab", format!("Request failed: {e}")))?;
286
287        let status = response.status();
288        let text = response
289            .text()
290            .await
291            .unwrap_or_else(|_| "<failed to read body>".to_string());
292
293        if !(200..300).contains(&status) {
294            return Err(Error::provider(
295                "gitlab",
296                format!("GitLab API error (HTTP {status}): {text}"),
297            ));
298        }
299
300        // Parse the response — try JSON first, fall back to plain text.
301        let response_text = if let Ok(parsed) = serde_json::from_str::<GitLabChatResponse>(&text) {
302            if !parsed.response.is_empty() {
303                parsed.response
304            } else if !parsed.content.is_empty() {
305                parsed.content
306            } else {
307                text
308            }
309        } else {
310            // Plain text response.
311            text
312        };
313
314        // Build the final assistant message.
315        let message = AssistantMessage {
316            content: vec![ContentBlock::Text(TextContent {
317                text: response_text.clone(),
318                text_signature: None,
319            })],
320            api: "gitlab-chat".to_string(),
321            provider: self.provider_name.clone(),
322            model: self.model.clone(),
323            usage: Usage::default(),
324            stop_reason: StopReason::Stop,
325            error_message: None,
326            timestamp: chrono::Utc::now().timestamp_millis(),
327        };
328
329        // GitLab Chat API is non-streaming, so we emit the full event sequence.
330        let events: Vec<Result<StreamEvent>> = vec![
331            Ok(StreamEvent::Start {
332                partial: message.clone(),
333            }),
334            Ok(StreamEvent::TextStart { content_index: 0 }),
335            Ok(StreamEvent::TextDelta {
336                content_index: 0,
337                delta: response_text.clone(),
338            }),
339            Ok(StreamEvent::TextEnd {
340                content_index: 0,
341                content: response_text,
342            }),
343            Ok(StreamEvent::Done {
344                reason: StopReason::Stop,
345                message,
346            }),
347        ];
348
349        Ok(Box::pin(stream::iter(events)))
350    }
351}
352
353// ── Tests ────────────────────────────────────────────────────────
354
355#[cfg(test)]
356mod tests {
357    use super::*;
358    use crate::model::UserMessage;
359
360    #[test]
361    fn test_gitlab_provider_defaults() {
362        let p = GitLabProvider::new("gitlab-duo-chat");
363        assert_eq!(p.name(), "gitlab");
364        assert_eq!(p.api(), "gitlab-chat");
365        assert_eq!(p.model_id(), "gitlab-duo-chat");
366        assert_eq!(p.base_url, DEFAULT_GITLAB_BASE);
367    }
368
369    #[test]
370    fn test_gitlab_provider_builder() {
371        let p = GitLabProvider::new("gitlab-duo-chat")
372            .with_provider_name("gitlab-duo")
373            .with_base_url("https://gitlab.example.com");
374
375        assert_eq!(p.name(), "gitlab-duo");
376        assert_eq!(p.base_url, "https://gitlab.example.com");
377    }
378
379    #[test]
380    fn test_gitlab_chat_url_construction() {
381        let p = GitLabProvider::new("model");
382        assert_eq!(p.chat_url(), "https://gitlab.com/api/v4/chat/completions");
383
384        let p = GitLabProvider::new("model").with_base_url("https://gitlab.example.com/");
385        assert_eq!(
386            p.chat_url(),
387            "https://gitlab.example.com/api/v4/chat/completions"
388        );
389    }
390
391    #[test]
392    fn test_build_request_simple() {
393        let context = Context::owned(
394            Some("Be helpful.".to_string()),
395            vec![Message::User(UserMessage {
396                content: UserContent::Text("How do I define a class?".to_string()),
397                timestamp: 0,
398            })],
399            Vec::new(),
400        );
401
402        let req = GitLabProvider::build_request(&context);
403        assert_eq!(req.content, "How do I define a class?");
404        assert_eq!(req.additional_context.len(), 1); // system prompt
405        assert_eq!(req.additional_context[0].id, "system-prompt");
406    }
407
408    #[test]
409    fn test_build_request_multi_turn() {
410        let context = Context::owned(
411            None,
412            vec![
413                Message::User(UserMessage {
414                    content: UserContent::Text("What is Rust?".to_string()),
415                    timestamp: 0,
416                }),
417                Message::assistant(AssistantMessage {
418                    content: vec![ContentBlock::Text(TextContent {
419                        text: "Rust is a systems language.".to_string(),
420                        text_signature: None,
421                    })],
422                    api: String::new(),
423                    provider: String::new(),
424                    model: String::new(),
425                    usage: Usage::default(),
426                    stop_reason: StopReason::default(),
427                    error_message: None,
428                    timestamp: 0,
429                }),
430                Message::User(UserMessage {
431                    content: UserContent::Text("Tell me more.".to_string()),
432                    timestamp: 0,
433                }),
434            ],
435            Vec::new(),
436        );
437
438        let req = GitLabProvider::build_request(&context);
439        assert_eq!(req.content, "Tell me more.");
440        // Should have 2 context items: first user msg + assistant response.
441        assert_eq!(req.additional_context.len(), 2);
442    }
443
444    #[test]
445    fn test_build_request_empty_messages_fallback() {
446        let context = Context::owned(None, Vec::new(), Vec::new());
447
448        let req = GitLabProvider::build_request(&context);
449        assert_eq!(req.content, "Hello"); // fallback
450    }
451
452    #[test]
453    fn test_gitlab_response_deserialization() {
454        let json = r#"{"response": "Here is how you define a class in Ruby..."}"#;
455        let resp: GitLabChatResponse = serde_json::from_str(json).expect("parse");
456        assert_eq!(resp.response, "Here is how you define a class in Ruby...");
457    }
458
459    #[test]
460    fn test_gitlab_response_content_field() {
461        let json = r#"{"content": "Alternative response format"}"#;
462        let resp: GitLabChatResponse = serde_json::from_str(json).expect("parse");
463        assert!(resp.response.is_empty());
464        assert_eq!(resp.content, "Alternative response format");
465    }
466
467    #[test]
468    fn test_gitlab_empty_base_url_uses_default() {
469        let p = GitLabProvider::new("model").with_base_url("");
470        assert_eq!(p.base_url, DEFAULT_GITLAB_BASE);
471    }
472
473    #[test]
474    fn test_gitlab_whitespace_base_url_uses_default() {
475        let p = GitLabProvider::new("model").with_base_url("   \n\t  ");
476        assert_eq!(p.base_url, DEFAULT_GITLAB_BASE);
477    }
478
479    #[test]
480    fn test_gitlab_base_url_is_trimmed() {
481        let p = GitLabProvider::new("model").with_base_url(" https://gitlab.example.com/ ");
482        assert_eq!(p.base_url, "https://gitlab.example.com/");
483        assert_eq!(
484            p.chat_url(),
485            "https://gitlab.example.com/api/v4/chat/completions"
486        );
487    }
488}