1use 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
28const DEFAULT_GITLAB_BASE: &str = "https://gitlab.com";
32
33const CHAT_API_PATH: &str = "/api/v4/chat/completions";
35
36#[derive(Debug, Serialize)]
40pub struct GitLabChatRequest {
41 content: String,
43 #[serde(skip_serializing_if = "Vec::is_empty")]
45 additional_context: Vec<GitLabContextItem>,
46}
47
48#[derive(Debug, Serialize)]
50struct GitLabContextItem {
51 category: String,
53 id: String,
55 content: String,
57}
58
59#[derive(Debug, Deserialize)]
61struct GitLabChatResponse {
62 #[serde(default)]
64 response: String,
65 #[serde(default)]
67 content: String,
68}
69
70pub struct GitLabProvider {
74 client: Client,
76 model: String,
78 base_url: String,
80 provider_name: String,
82 #[allow(dead_code)]
84 compat: Option<CompatConfig>,
85}
86
87impl GitLabProvider {
88 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 #[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 #[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 #[must_use]
119 pub fn with_compat(mut self, compat: Option<CompatConfig>) -> Self {
120 self.compat = compat;
121 self
122 }
123
124 #[must_use]
126 pub fn with_client(mut self, client: Client) -> Self {
127 self.client = client;
128 self
129 }
130
131 fn chat_url(&self) -> String {
133 let base = self.base_url.trim_end_matches('/');
134 format!("{base}{CHAT_API_PATH}")
135 }
136
137 pub fn build_request(context: &Context<'_>) -> GitLabChatRequest {
139 let mut content = String::new();
141 let mut additional_context = Vec::new();
142
143 for (i, msg) in context.messages.iter().enumerate().rev() {
146 match msg {
147 Message::User(user_msg) => {
148 if content.is_empty() {
149 match &user_msg.content {
151 UserContent::Text(text) => content.clone_from(text),
152 UserContent::Blocks(blocks) => {
153 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 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 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 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 additional_context.reverse();
227
228 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 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 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 text
312 };
313
314 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 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#[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); 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 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"); }
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}