1use async_trait::async_trait;
7use std::collections::HashMap;
8use std::sync::{Arc, OnceLock};
9use tokio::sync::Mutex;
10
11use crate::config::{BackendConfig, BackendProtocol, CredentialConfig, GatewayConfig};
12use crate::message::InboundMessage;
13
14pub struct GatewayContext {
16 pub gateway_url: String,
17 pub send_token: String,
18}
19
20static OPENCODE_SESSIONS: OnceLock<Arc<Mutex<HashMap<String, String>>>> = OnceLock::new();
24
25fn get_opencode_sessions() -> Arc<Mutex<HashMap<String, String>>> {
26 OPENCODE_SESSIONS
27 .get_or_init(|| Arc::new(Mutex::new(HashMap::new())))
28 .clone()
29}
30
31#[derive(Debug, thiserror::Error)]
33pub enum BackendError {
34 #[error("Network error: {0}")]
35 Network(#[from] reqwest::Error),
36
37 #[error("Backend returned error: {status} - {message}")]
38 #[allow(clippy::enum_variant_names)]
39 BackendResponse { status: u16, message: String },
40
41 #[error("Invalid configuration: {0}")]
42 InvalidConfig(String),
43
44 #[error("Timeout waiting for response")]
45 #[allow(dead_code)]
46 Timeout,
47}
48
49#[async_trait]
51pub trait BackendAdapter: Send + Sync {
52 async fn send_message(&self, message: &InboundMessage) -> Result<(), BackendError>;
54
55 #[allow(dead_code)]
57 fn supports_files(&self) -> bool;
58}
59
60pub struct PipelitAdapter {
62 client: reqwest::Client,
63 inbound_url: String,
64 token: String,
65}
66
67impl PipelitAdapter {
68 pub fn new(
69 target: &BackendConfig,
70 _gateway_ctx: Option<&GatewayContext>,
71 _credential_config: Option<&serde_json::Value>,
72 ) -> Result<Self, BackendError> {
73 let inbound_url = target.inbound_url.clone().ok_or_else(|| {
74 BackendError::InvalidConfig("Pipelit target requires inbound_url".to_string())
75 })?;
76
77 Ok(Self {
78 client: reqwest::Client::new(),
79 inbound_url,
80 token: target.token.clone(),
81 })
82 }
83}
84
85#[async_trait]
86impl BackendAdapter for PipelitAdapter {
87 async fn send_message(&self, message: &InboundMessage) -> Result<(), BackendError> {
88 let response = self
89 .client
90 .post(&self.inbound_url)
91 .header("Authorization", format!("Bearer {}", self.token))
92 .json(message)
93 .send()
94 .await?;
95
96 if response.status().is_success() {
97 Ok(())
98 } else {
99 let status = response.status().as_u16();
100 let message = response
101 .text()
102 .await
103 .unwrap_or_else(|_| "Unknown error".to_string());
104 Err(BackendError::BackendResponse { status, message })
105 }
106 }
107
108 fn supports_files(&self) -> bool {
109 true
110 }
111}
112
113pub struct OpencodeAdapter {
114 base_url: String,
115 token: String,
116 gateway_url: String,
117 send_token: String,
118 credential_config: Option<serde_json::Value>,
119 sessions: Arc<Mutex<HashMap<String, String>>>,
120}
121
122impl OpencodeAdapter {
123 pub fn new(
124 target: &BackendConfig,
125 gateway_ctx: Option<&GatewayContext>,
126 credential_config: Option<&serde_json::Value>,
127 ) -> Result<Self, BackendError> {
128 let base_url = target.base_url.clone().ok_or_else(|| {
129 BackendError::InvalidConfig("OpenCode target requires base_url".to_string())
130 })?;
131
132 Ok(Self {
133 base_url,
134 token: target.token.clone(),
135 gateway_url: gateway_ctx
136 .map(|ctx| ctx.gateway_url.clone())
137 .unwrap_or_default(),
138 send_token: gateway_ctx
139 .map(|ctx| ctx.send_token.clone())
140 .unwrap_or_default(),
141 credential_config: credential_config.cloned(),
142 sessions: get_opencode_sessions(),
143 })
144 }
145}
146
147#[async_trait]
148impl BackendAdapter for OpencodeAdapter {
149 async fn send_message(&self, message: &InboundMessage) -> Result<(), BackendError> {
150 let colon_pos = self.token.find(':').ok_or_else(|| {
152 BackendError::InvalidConfig(
153 "OpenCode token must be in 'username:password' format".to_string(),
154 )
155 })?;
156 let username = &self.token[..colon_pos];
157 let password = &self.token[colon_pos + 1..];
158
159 let model = self
160 .credential_config
161 .as_ref()
162 .and_then(|c| c.get("model"))
163 .ok_or_else(|| {
164 BackendError::InvalidConfig("Missing 'model' in credential config".to_string())
165 })?;
166
167 let client = reqwest::Client::builder()
169 .timeout(std::time::Duration::from_secs(120))
170 .build()?;
171
172 let chat_id = &message.source.chat_id;
173 let session_key = format!("{}:{}", message.credential_id, chat_id);
174
175 let session_id = {
176 let mut sessions = self.sessions.lock().await;
177 if let Some(id) = sessions.get(&session_key) {
178 id.clone()
179 } else {
180 tracing::info!(
181 credential_id = %message.credential_id,
182 chat_id = %chat_id,
183 "Creating new OpenCode session"
184 );
185
186 let resp = client
187 .post(format!("{}/session", self.base_url))
188 .basic_auth(username, Some(password))
189 .send()
190 .await?;
191
192 if !resp.status().is_success() {
193 let status = resp.status().as_u16();
194 let body = resp
195 .text()
196 .await
197 .unwrap_or_else(|_| "Unknown error".to_string());
198 return Err(BackendError::BackendResponse {
199 status,
200 message: body,
201 });
202 }
203
204 let body: serde_json::Value = resp.json().await?;
205 let new_session_id = body
206 .get("id")
207 .and_then(|v| v.as_str())
208 .ok_or_else(|| {
209 BackendError::InvalidConfig(
210 "OpenCode session response missing 'id' field".to_string(),
211 )
212 })?
213 .to_string();
214
215 sessions.insert(session_key.clone(), new_session_id.clone());
216 new_session_id
217 }
218 };
219
220 tracing::info!(
221 credential_id = %message.credential_id,
222 chat_id = %chat_id,
223 session_id = %session_id,
224 "Sending message to OpenCode"
225 );
226
227 let msg_body = serde_json::json!({
228 "model": model,
229 "parts": [{"type": "text", "text": message.text}]
230 });
231
232 let resp = client
233 .post(format!("{}/session/{}/message", self.base_url, session_id))
234 .basic_auth(username, Some(password))
235 .json(&msg_body)
236 .send()
237 .await?;
238
239 if !resp.status().is_success() {
240 let status = resp.status().as_u16();
241 let body = resp
242 .text()
243 .await
244 .unwrap_or_else(|_| "Unknown error".to_string());
245 return Err(BackendError::BackendResponse {
246 status,
247 message: body,
248 });
249 }
250
251 let resp_body: serde_json::Value = resp.json().await?;
253 let ai_response = resp_body
254 .get("parts")
255 .and_then(|v| v.as_array())
256 .map(|parts| {
257 parts
258 .iter()
259 .filter(|p| p.get("type").and_then(|t| t.as_str()) == Some("text"))
260 .filter_map(|p| p.get("text").and_then(|t| t.as_str()))
261 .collect::<Vec<_>>()
262 .join("\n\n")
263 })
264 .unwrap_or_default();
265
266 tracing::info!(
268 credential_id = %message.credential_id,
269 chat_id = %chat_id,
270 "Relaying OpenCode response to gateway"
271 );
272
273 let relay_body = serde_json::json!({
274 "credential_id": message.credential_id,
275 "chat_id": chat_id,
276 "text": ai_response,
277 });
278
279 match client
280 .post(format!("{}/api/v1/send", self.gateway_url))
281 .header("Authorization", format!("Bearer {}", self.send_token))
282 .json(&relay_body)
283 .send()
284 .await
285 {
286 Ok(resp) if !resp.status().is_success() => {
287 let status = resp.status();
288 tracing::error!(
289 credential_id = %message.credential_id,
290 chat_id = %chat_id,
291 error = %format!("HTTP {}", status),
292 "Failed to relay OpenCode response"
293 );
294 }
295 Err(e) => {
296 tracing::error!(
297 credential_id = %message.credential_id,
298 chat_id = %chat_id,
299 error = %e,
300 "Failed to relay OpenCode response"
301 );
302 }
303 Ok(_) => {}
304 }
305
306 Ok(())
307 }
308
309 fn supports_files(&self) -> bool {
310 false }
312}
313
314pub struct ExternalBackendAdapter {
315 port: u16,
316 token: String,
317 client: reqwest::Client,
318}
319
320impl ExternalBackendAdapter {
321 pub fn new(port: u16, token: String) -> Result<Self, BackendError> {
322 Ok(Self {
323 port,
324 token,
325 client: reqwest::Client::builder()
326 .timeout(std::time::Duration::from_secs(120))
327 .build()?,
328 })
329 }
330}
331
332#[async_trait]
333impl BackendAdapter for ExternalBackendAdapter {
334 async fn send_message(&self, message: &InboundMessage) -> Result<(), BackendError> {
335 let url = format!("http://127.0.0.1:{}/send", self.port);
336
337 tracing::info!(
338 port = %self.port,
339 credential_id = %message.credential_id,
340 "Sending message to external backend adapter"
341 );
342
343 let response = self
344 .client
345 .post(&url)
346 .header("Authorization", format!("Bearer {}", self.token))
347 .json(message)
348 .send()
349 .await?;
350
351 if response.status().is_success() {
352 Ok(())
353 } else {
354 let status = response.status().as_u16();
355 let message = response
356 .text()
357 .await
358 .unwrap_or_else(|_| "Unknown error".to_string());
359 Err(BackendError::BackendResponse { status, message })
360 }
361 }
362
363 fn supports_files(&self) -> bool {
364 false
365 }
366}
367
368#[allow(dead_code)]
370pub struct ExternalBackendManager {
371 backends_dir: String,
372 port_allocator: crate::adapter::PortAllocator,
373 gateway_url: String,
374 gateway_send_token: String,
375 processes: tokio::sync::RwLock<HashMap<String, ExternalBackendProcess>>,
376}
377
378#[allow(dead_code)]
379pub struct ExternalBackendProcess {
380 pub instance_id: String,
381 pub port: u16,
382 pub token: String,
383 pub process: tokio::process::Child,
384 pub adapter_dir: String,
385}
386
387#[allow(dead_code)]
388impl ExternalBackendManager {
389 pub fn new(
390 backends_dir: String,
391 port_range: (u16, u16),
392 gateway_listen: &str,
393 gateway_send_token: String,
394 ) -> Self {
395 let gateway_url = if gateway_listen.starts_with("0.0.0.0") {
396 format!(
397 "http://127.0.0.1:{}",
398 gateway_listen.split(':').next_back().unwrap_or("8080")
399 )
400 } else {
401 format!("http://{}", gateway_listen)
402 };
403
404 Self {
405 backends_dir,
406 port_allocator: crate::adapter::PortAllocator::new(port_range),
407 gateway_url,
408 gateway_send_token,
409 processes: tokio::sync::RwLock::new(HashMap::new()),
410 }
411 }
412
413 pub async fn spawn(
414 &self,
415 backend_name: &str,
416 backend_cfg: &BackendConfig,
417 ) -> Result<(u16, String), BackendError> {
418 let adapter_dir = backend_cfg
419 .adapter_dir
420 .as_ref()
421 .map(std::path::PathBuf::from)
422 .unwrap_or_else(|| std::path::Path::new(&self.backends_dir).join(backend_name));
423
424 let adapter_def = crate::adapter::load_adapter_def(&adapter_dir).map_err(|e| {
425 BackendError::InvalidConfig(format!("Failed to load backend adapter def: {}", e))
426 })?;
427
428 let port = self.port_allocator.allocate().await.ok_or_else(|| {
429 BackendError::InvalidConfig("No available ports for backend adapter".to_string())
430 })?;
431
432 let instance_id = format!("backend_{}_{}", backend_name, uuid::Uuid::new_v4());
433 let backend_token = uuid::Uuid::new_v4().to_string();
434
435 let mut cmd = tokio::process::Command::new(&adapter_def.command);
436 cmd.args(&adapter_def.args)
437 .current_dir(&adapter_dir)
438 .env("INSTANCE_ID", &instance_id)
439 .env("BACKEND_PORT", port.to_string())
440 .env("GATEWAY_URL", &self.gateway_url)
441 .env("BACKEND_TOKEN", &backend_token)
442 .env("GATEWAY_SEND_TOKEN", &self.gateway_send_token)
443 .stdin(std::process::Stdio::null())
444 .stdout(std::process::Stdio::null())
445 .stderr(std::process::Stdio::piped());
446
447 if let Some(cfg) = &backend_cfg.config {
448 cmd.env(
449 "BACKEND_CONFIG",
450 serde_json::to_string(cfg).unwrap_or_default(),
451 );
452 }
453
454 tracing::info!(
455 backend = %backend_name,
456 port = %port,
457 instance_id = %instance_id,
458 "Spawning external backend adapter process"
459 );
460
461 let mut process = match cmd.spawn() {
462 Ok(p) => p,
463 Err(e) => {
464 self.port_allocator.release(port).await;
465 return Err(BackendError::InvalidConfig(format!(
466 "Failed to spawn backend adapter process: {}",
467 e
468 )));
469 }
470 };
471
472 if let Some(stderr) = process.stderr.take() {
474 let name = backend_name.to_string();
475 tokio::spawn(async move {
476 use tokio::io::{AsyncBufReadExt, BufReader};
477 let mut lines = BufReader::new(stderr).lines();
478 while let Ok(Some(line)) = lines.next_line().await {
479 tracing::info!(backend = %name, "{}", line);
480 }
481 });
482 }
483
484 let mut processes = self.processes.write().await;
485 processes.insert(
486 backend_name.to_string(),
487 ExternalBackendProcess {
488 instance_id,
489 port,
490 token: backend_token.clone(),
491 process,
492 adapter_dir: adapter_dir.to_string_lossy().to_string(),
493 },
494 );
495
496 Ok((port, backend_token))
497 }
498
499 #[allow(dead_code)]
500 pub async fn get_port(&self, backend_name: &str) -> Option<u16> {
501 let processes = self.processes.read().await;
502 processes.get(backend_name).map(|p| p.port)
503 }
504
505 pub async fn stop(&self, backend_name: &str) {
506 let mut processes = self.processes.write().await;
507 if let Some(mut process) = processes.remove(backend_name) {
508 let _ = process.process.kill().await;
509 let _ = process.process.wait().await;
510 self.port_allocator.release(process.port).await;
511 tracing::info!(
512 backend = %backend_name,
513 port = %process.port,
514 "Stopped external backend adapter"
515 );
516 }
517 }
518
519 pub async fn stop_all(&self) {
520 let mut processes = self.processes.write().await;
521 for (name, mut process) in processes.drain() {
522 let _ = process.process.kill().await;
523 let _ = process.process.wait().await;
524 self.port_allocator.release(process.port).await;
525 tracing::info!(
526 backend = %name,
527 port = %process.port,
528 "Stopped external backend adapter"
529 );
530 }
531 }
532}
533
534pub fn create_adapter(
535 target: &BackendConfig,
536 gateway_ctx: Option<&GatewayContext>,
537 credential_config: Option<&serde_json::Value>,
538) -> Result<Arc<dyn BackendAdapter>, BackendError> {
539 match target.protocol {
540 BackendProtocol::Pipelit => Ok(Arc::new(PipelitAdapter::new(
541 target,
542 gateway_ctx,
543 credential_config,
544 )?)),
545 BackendProtocol::Opencode => Ok(Arc::new(OpencodeAdapter::new(
546 target,
547 gateway_ctx,
548 credential_config,
549 )?)),
550 BackendProtocol::External => {
551 let port = target.port.ok_or_else(|| {
552 BackendError::InvalidConfig(
553 "External backend adapter requires 'port' in backend config".to_string(),
554 )
555 })?;
556 Ok(Arc::new(ExternalBackendAdapter::new(
557 port,
558 target.token.clone(),
559 )?))
560 }
561 }
562}
563
564pub fn resolve_backend_name(
567 credential: &CredentialConfig,
568 gateway: &GatewayConfig,
569) -> Option<String> {
570 credential
571 .backend
572 .clone()
573 .or_else(|| gateway.default_backend.clone())
574}
575
576pub async fn wait_for_backend_ready(
578 port: u16,
579 timeout: std::time::Duration,
580 interval: std::time::Duration,
581) -> bool {
582 let client = reqwest::Client::builder()
583 .timeout(std::time::Duration::from_secs(2))
584 .build()
585 .unwrap_or_else(|_| reqwest::Client::new());
586
587 let url = format!("http://127.0.0.1:{}/health", port);
588 let deadline = tokio::time::Instant::now() + timeout;
589
590 loop {
591 match client.get(&url).send().await {
592 Ok(resp) if resp.status().is_success() => return true,
593 _ => {}
594 }
595 if tokio::time::Instant::now() >= deadline {
596 return false;
597 }
598 tokio::time::sleep(interval).await;
599 }
600}
601
602#[cfg(test)]
603mod tests {
604 use super::*;
605 use crate::config::{BackendConfig, BackendProtocol};
606 use crate::message::{InboundMessage, MessageSource, UserInfo};
607
608 fn make_opencode_target(token: &str) -> BackendConfig {
609 BackendConfig {
610 protocol: BackendProtocol::Opencode,
611 inbound_url: None,
612 base_url: Some("http://localhost:4096".to_string()),
613 token: token.to_string(),
614 poll_interval_ms: None,
615 adapter_dir: None,
616 port: None,
617 active: true,
618 config: None,
619 }
620 }
621
622 fn make_dummy_message() -> InboundMessage {
623 InboundMessage {
624 route: serde_json::json!({}),
625 credential_id: "test_cred".to_string(),
626 source: MessageSource {
627 protocol: "test".to_string(),
628 chat_id: "chat_123".to_string(),
629 message_id: "msg_1".to_string(),
630 reply_to_message_id: None,
631 from: UserInfo {
632 id: "user_1".to_string(),
633 username: None,
634 display_name: None,
635 },
636 },
637 text: "Hello".to_string(),
638 attachments: vec![],
639 timestamp: chrono::Utc::now(),
640 extra_data: None,
641 }
642 }
643
644 #[test]
645 fn test_pipelit_adapter_requires_inbound_url() {
646 let target = BackendConfig {
647 protocol: BackendProtocol::Pipelit,
648 inbound_url: None,
649 base_url: None,
650 token: "test".to_string(),
651 poll_interval_ms: None,
652 adapter_dir: None,
653 port: None,
654 active: true,
655 config: None,
656 };
657
658 let result = PipelitAdapter::new(&target, None, None);
659 assert!(result.is_err());
660 }
661
662 #[test]
663 fn test_pipelit_adapter_creation() {
664 let target = BackendConfig {
665 protocol: BackendProtocol::Pipelit,
666 inbound_url: Some("http://localhost:8000/inbound".to_string()),
667 base_url: None,
668 token: "test".to_string(),
669 poll_interval_ms: None,
670 adapter_dir: None,
671 port: None,
672 active: true,
673 config: None,
674 };
675
676 let adapter = PipelitAdapter::new(&target, None, None).unwrap();
677 assert!(adapter.supports_files());
678 }
679
680 #[test]
681 fn test_opencode_adapter_requires_base_url() {
682 let target = BackendConfig {
683 protocol: BackendProtocol::Opencode,
684 inbound_url: None,
685 base_url: None,
686 token: "test".to_string(),
687 poll_interval_ms: None,
688 adapter_dir: None,
689 port: None,
690 active: true,
691 config: None,
692 };
693
694 let result = OpencodeAdapter::new(&target, None, None);
695 assert!(result.is_err());
696 }
697
698 #[test]
699 fn test_opencode_adapter_creation() {
700 let target = BackendConfig {
701 protocol: BackendProtocol::Opencode,
702 inbound_url: None,
703 base_url: Some("http://localhost:4096".to_string()),
704 token: "test".to_string(),
705 poll_interval_ms: Some(1000),
706 adapter_dir: None,
707 port: None,
708 active: true,
709 config: None,
710 };
711
712 let adapter = OpencodeAdapter::new(&target, None, None).unwrap();
713 assert!(!adapter.supports_files());
714 }
715
716 #[test]
717 fn test_opencode_adapter_valid_token_parsing() {
718 let target = make_opencode_target("myuser:mypass");
719 let result = OpencodeAdapter::new(&target, None, None);
720 assert!(result.is_ok(), "Adapter with valid token should succeed");
721 }
722
723 #[tokio::test]
724 async fn test_opencode_adapter_token_no_colon() {
725 let target = make_opencode_target("nodelimiter");
726 let adapter = OpencodeAdapter::new(&target, None, None).unwrap();
727 let msg = make_dummy_message();
728 let result = adapter.send_message(&msg).await;
729 assert!(result.is_err());
730 let err_str = result.unwrap_err().to_string();
731 assert!(
732 err_str.contains("username:password"),
733 "Error should mention 'username:password', got: {err_str}"
734 );
735 }
736
737 #[test]
738 fn test_opencode_adapter_token_colon_in_password() {
739 let target = make_opencode_target("user:pass:with:colons");
740 let result = OpencodeAdapter::new(&target, None, None);
741 assert!(
742 result.is_ok(),
743 "Token with colon in password should be accepted"
744 );
745 }
746
747 #[tokio::test]
748 async fn test_opencode_adapter_missing_credential_config() {
749 let target = make_opencode_target("user:pass");
750 let adapter = OpencodeAdapter::new(&target, None, None).unwrap();
751 let msg = make_dummy_message();
752 let result = adapter.send_message(&msg).await;
753 assert!(result.is_err());
754 let err_str = result.unwrap_err().to_string();
755 assert!(
756 err_str.contains("model"),
757 "Error should mention 'model', got: {err_str}"
758 );
759 }
760
761 #[tokio::test]
762 async fn test_opencode_adapter_missing_model_in_config() {
763 let target = make_opencode_target("user:pass");
764 let config = serde_json::json!({});
765 let adapter = OpencodeAdapter::new(&target, None, Some(&config)).unwrap();
766 let msg = make_dummy_message();
767 let result = adapter.send_message(&msg).await;
768 assert!(result.is_err());
769 let err_str = result.unwrap_err().to_string();
770 assert!(
771 err_str.contains("model"),
772 "Error should mention 'model', got: {err_str}"
773 );
774 }
775
776 #[test]
777 fn test_opencode_adapter_valid_model_config() {
778 let target = make_opencode_target("user:pass");
779 let config = serde_json::json!({
780 "model": {
781 "providerID": "test",
782 "modelID": "test-model"
783 }
784 });
785 let result = OpencodeAdapter::new(&target, None, Some(&config));
786 assert!(
787 result.is_ok(),
788 "Adapter with valid model config should succeed"
789 );
790 }
791
792 #[test]
793 fn test_opencode_adapter_creation_with_gateway_ctx() {
794 let target = make_opencode_target("user:pass");
795 let config = serde_json::json!({
796 "model": {
797 "providerID": "test",
798 "modelID": "test-model"
799 }
800 });
801 let gateway_ctx = GatewayContext {
802 gateway_url: "http://localhost:8080".to_string(),
803 send_token: "test_token".to_string(),
804 };
805 let result = OpencodeAdapter::new(&target, Some(&gateway_ctx), Some(&config));
806 assert!(
807 result.is_ok(),
808 "Adapter with full valid config should succeed"
809 );
810 }
811
812 #[test]
813 fn test_opencode_adapter_supports_files_false() {
814 let target = make_opencode_target("user:pass");
815 let adapter = OpencodeAdapter::new(&target, None, None).unwrap();
816 assert!(
817 !adapter.supports_files(),
818 "OpencodeAdapter should not support files"
819 );
820 }
821
822 #[test]
823 fn test_external_backend_adapter_creation() {
824 let adapter = ExternalBackendAdapter::new(9200, "test_token".to_string()).unwrap();
825 assert_eq!(adapter.port, 9200);
826 assert_eq!(adapter.token, "test_token");
827 }
828
829 #[test]
830 fn test_external_backend_adapter_supports_files() {
831 let adapter = ExternalBackendAdapter::new(9200, "token".to_string()).unwrap();
832 assert!(
833 !adapter.supports_files(),
834 "ExternalBackendAdapter should not support files"
835 );
836 }
837
838 #[test]
839 fn test_create_adapter_external_requires_port() {
840 let target = BackendConfig {
841 protocol: BackendProtocol::External,
842 inbound_url: None,
843 base_url: None,
844 token: "test".to_string(),
845 poll_interval_ms: None,
846 adapter_dir: Some("./backends/opencode".to_string()),
847 port: None,
848 active: true,
849 config: None,
850 };
851
852 let result = create_adapter(&target, None, None);
853 assert!(result.is_err());
854 let err_str = match result {
855 Err(e) => e.to_string(),
856 Ok(_) => panic!("Expected error"),
857 };
858 assert!(
859 err_str.contains("port"),
860 "Error should mention 'port', got: {err_str}"
861 );
862 }
863
864 #[test]
865 fn test_create_adapter_external_with_port() {
866 let target = BackendConfig {
867 protocol: BackendProtocol::External,
868 inbound_url: None,
869 base_url: None,
870 token: "ext_token".to_string(),
871 poll_interval_ms: None,
872 adapter_dir: Some("./backends/opencode".to_string()),
873 port: Some(9200),
874 active: true,
875 config: None,
876 };
877
878 let result = create_adapter(&target, None, None);
879 assert!(result.is_ok(), "External adapter with port should succeed");
880 }
881}