Skip to main content

bamboo_agent/server/handlers/
settings.rs

1use crate::core::keyword_masking::{KeywordEntry, KeywordMaskingConfig};
2use crate::core::{Config, ProxyAuth};
3use crate::server::{app_state::AppState, error::AppError};
4use actix_web::{web, HttpResponse};
5use chrono::{SecondsFormat, Utc};
6use serde::{Deserialize, Serialize};
7use serde_json::Value;
8use tokio::fs;
9
10use crate::agent::llm::AVAILABLE_PROVIDERS;
11
12// ============================================================================
13// Response Types
14// ============================================================================
15
16/// Workflow list item for API responses
17#[derive(Serialize)]
18struct WorkflowListItem {
19    /// Workflow name
20    name: String,
21    /// Filename (e.g., "myworkflow.md")
22    filename: String,
23    /// File size in bytes
24    size: u64,
25    /// Last modified timestamp (currently not populated)
26    modified_at: Option<String>,
27}
28
29/// Full workflow data with content
30#[derive(Serialize)]
31struct WorkflowGetResponse {
32    /// Workflow name
33    name: String,
34    /// Filename
35    filename: String,
36    /// Workflow markdown content
37    content: String,
38    /// File size in bytes
39    size: u64,
40    /// Last modified timestamp (currently not populated)
41    modified_at: Option<String>,
42}
43
44// ============================================================================
45// Helper Functions
46// ============================================================================
47
48fn is_masked_api_key(value: &str) -> bool {
49    let v = value.trim();
50    v.is_empty() || v.contains("***") || v.contains("...") || v == "****...****"
51}
52
53fn redact_config_for_api(mut value: Value, config: &Config) -> Value {
54    // Never send decrypted secrets. Also avoid sending encrypted key material.
55    if let Some(root) = value.as_object_mut() {
56        root.remove("proxy_auth_encrypted");
57
58        if let Some(providers) = root.get_mut("providers").and_then(|v| v.as_object_mut()) {
59            for (name, provider_cfg) in providers.iter_mut() {
60                let Some(provider_obj) = provider_cfg.as_object_mut() else {
61                    continue;
62                };
63
64                provider_obj.remove("api_key_encrypted");
65
66                let configured = match name.as_str() {
67                    "openai" => config
68                        .providers
69                        .openai
70                        .as_ref()
71                        .map(|c| !c.api_key.trim().is_empty() || c.api_key_encrypted.is_some())
72                        .unwrap_or(false),
73                    "anthropic" => config
74                        .providers
75                        .anthropic
76                        .as_ref()
77                        .map(|c| !c.api_key.trim().is_empty() || c.api_key_encrypted.is_some())
78                        .unwrap_or(false),
79                    "gemini" => config
80                        .providers
81                        .gemini
82                        .as_ref()
83                        .map(|c| !c.api_key.trim().is_empty() || c.api_key_encrypted.is_some())
84                        .unwrap_or(false),
85                    _ => false,
86                };
87
88                if configured {
89                    provider_obj.insert(
90                        "api_key".to_string(),
91                        Value::String("****...****".to_string()),
92                    );
93                } else {
94                    provider_obj.remove("api_key");
95                }
96            }
97        }
98
99        // MCP config may contain credentials in env vars / headers. Do not return either plaintext
100        // or encrypted blobs to clients; return masked placeholders instead.
101        if let Some(mcp) = root.get_mut("mcp").and_then(|v| v.as_object_mut()) {
102            if let Some(servers) = mcp.get_mut("servers").and_then(|v| v.as_array_mut()) {
103                for server in servers.iter_mut() {
104                    let Some(server_obj) = server.as_object_mut() else {
105                        continue;
106                    };
107                    let server_id = server_obj
108                        .get("id")
109                        .and_then(|v| v.as_str())
110                        .unwrap_or_default()
111                        .to_string();
112
113                    let Some(transport) = server_obj
114                        .get_mut("transport")
115                        .and_then(|v| v.as_object_mut())
116                    else {
117                        continue;
118                    };
119
120                    let transport_type = transport
121                        .get("type")
122                        .and_then(|v| v.as_str())
123                        .unwrap_or_default();
124
125                    match transport_type {
126                        "stdio" => {
127                            // `env` is kept in-memory only; `env_encrypted` is persisted. We strip
128                            // encrypted values and return env keys with masked placeholders.
129                            let mut keys: Vec<String> = transport
130                                .get("env_encrypted")
131                                .and_then(|v| v.as_object())
132                                .map(|obj| obj.keys().cloned().collect())
133                                .unwrap_or_default();
134
135                            if keys.is_empty() {
136                                // Best-effort: fall back to hydrated in-memory env keys.
137                                if let Some(cfg_server) =
138                                    config.mcp.servers.iter().find(|s| s.id == server_id)
139                                {
140                                    if let crate::agent::mcp::TransportConfig::Stdio(stdio) =
141                                        &cfg_server.transport
142                                    {
143                                        keys = stdio.env.keys().cloned().collect();
144                                    }
145                                }
146                            }
147
148                            transport.remove("env_encrypted");
149                            let env_obj = keys
150                                .into_iter()
151                                .map(|k| (k, Value::String("****...****".to_string())))
152                                .collect::<serde_json::Map<String, Value>>();
153                            transport.insert("env".to_string(), Value::Object(env_obj));
154                        }
155                        "sse" => {
156                            if let Some(headers) =
157                                transport.get_mut("headers").and_then(|v| v.as_array_mut())
158                            {
159                                for header in headers.iter_mut() {
160                                    let Some(header_obj) = header.as_object_mut() else {
161                                        continue;
162                                    };
163                                    header_obj.remove("value_encrypted");
164                                    // Always mask header values.
165                                    header_obj.insert(
166                                        "value".to_string(),
167                                        Value::String("****...****".to_string()),
168                                    );
169                                }
170                            }
171                        }
172                        _ => {}
173                    }
174                }
175            }
176        }
177    }
178
179    value
180}
181
182fn redact_providers_for_api(mut value: Value, config: &Config) -> Value {
183    let Some(obj) = value.as_object_mut() else {
184        return value;
185    };
186
187    for (name, provider_cfg) in obj.iter_mut() {
188        let Some(provider_obj) = provider_cfg.as_object_mut() else {
189            continue;
190        };
191
192        provider_obj.remove("api_key_encrypted");
193
194        let configured = match name.as_str() {
195            "openai" => config
196                .providers
197                .openai
198                .as_ref()
199                .map(|c| !c.api_key.trim().is_empty() || c.api_key_encrypted.is_some())
200                .unwrap_or(false),
201            "anthropic" => config
202                .providers
203                .anthropic
204                .as_ref()
205                .map(|c| !c.api_key.trim().is_empty() || c.api_key_encrypted.is_some())
206                .unwrap_or(false),
207            "gemini" => config
208                .providers
209                .gemini
210                .as_ref()
211                .map(|c| !c.api_key.trim().is_empty() || c.api_key_encrypted.is_some())
212                .unwrap_or(false),
213            _ => false,
214        };
215
216        if configured {
217            provider_obj.insert(
218                "api_key".to_string(),
219                Value::String("****...****".to_string()),
220            );
221        } else {
222            provider_obj.remove("api_key");
223        }
224    }
225
226    value
227}
228
229/// Validates workflow names for security (prevents path traversal, etc.)
230fn is_safe_workflow_name(name: &str) -> bool {
231    // Check basic constraints
232    if name.is_empty() || name.len() > 255 {
233        return false;
234    }
235
236    // Trim and check for whitespace issues
237    let trimmed = name.trim();
238    if trimmed != name || trimmed.is_empty() {
239        return false;
240    }
241
242    // Check for path separators and traversal patterns
243    if name.contains('/') || name.contains('\\') || name.contains("..") {
244        return false;
245    }
246
247    // Check for null bytes and control characters
248    if name.chars().any(|c| c.is_control() || c == '\0') {
249        return false;
250    }
251
252    // Check for reserved Windows names
253    let upper = name.to_uppercase();
254    let stem = upper.split('.').next().unwrap_or(&upper);
255    let reserved = [
256        "CON", "PRN", "AUX", "NUL", "COM1", "COM2", "COM3", "COM4", "COM5", "COM6", "COM7", "COM8",
257        "COM9", "LPT1", "LPT2", "LPT3", "LPT4", "LPT5", "LPT6", "LPT7", "LPT8", "LPT9",
258    ];
259    if reserved.contains(&stem) {
260        return false;
261    }
262
263    // Only allow alphanumeric, dash, underscore, dot, and space
264    name.chars()
265        .all(|c| c.is_alphanumeric() || c == '-' || c == '_' || c == '.' || c == ' ')
266}
267
268// ============================================================================
269// Workflow Handlers
270// ============================================================================
271
272/// Lists all workflow markdown files
273///
274/// # HTTP Route
275/// `GET /bamboo/workflows`
276///
277/// # Response Format
278/// Returns array of [`WorkflowListItem`]:
279/// ```json
280/// [
281///   {
282///     "name": "myworkflow",
283///     "filename": "myworkflow.md",
284///     "size": 1234,
285///     "modified_at": null
286///   }
287/// ]
288/// ```
289///
290/// # Response Status
291/// - `200 OK`: Successfully retrieved workflow list
292///
293/// # Example
294/// ```bash
295/// curl http://localhost:3000/bamboo/workflows
296/// ```
297pub async fn list_workflows(app_state: web::Data<AppState>) -> Result<HttpResponse, AppError> {
298    let dir = app_state.app_data_dir.join("workflows");
299
300    fs::create_dir_all(&dir).await?;
301
302    let mut entries = fs::read_dir(&dir).await?;
303    let mut workflows: Vec<WorkflowListItem> = Vec::new();
304
305    while let Some(entry) = entries.next_entry().await? {
306        let file_type = entry.file_type().await?;
307        if !file_type.is_file() {
308            continue;
309        }
310
311        let path = entry.path();
312        if path.extension().and_then(|s| s.to_str()) != Some("md") {
313            continue;
314        }
315
316        let Some(stem) = path.file_stem().and_then(|s| s.to_str()) else {
317            continue;
318        };
319
320        let filename = path
321            .file_name()
322            .and_then(|s| s.to_str())
323            .unwrap_or_default()
324            .to_string();
325
326        let metadata = entry.metadata().await?;
327        workflows.push(WorkflowListItem {
328            name: stem.to_string(),
329            filename,
330            size: metadata.len(),
331            modified_at: None,
332        });
333    }
334
335    workflows.sort_by(|a, b| a.name.cmp(&b.name));
336
337    Ok(HttpResponse::Ok().json(workflows))
338}
339
340/// Gets a specific workflow by name
341///
342/// # HTTP Route
343/// `GET /bamboo/workflows/{name}`
344///
345/// # Path Parameters
346/// - `name`: Workflow name (without .md extension)
347///
348/// # Response Format
349/// Returns [`WorkflowGetResponse`] with full content:
350/// ```json
351/// {
352///   "name": "myworkflow",
353///   "filename": "myworkflow.md",
354///   "content": "# My Workflow\n...",
355///   "size": 1234,
356///   "modified_at": null
357/// }
358/// ```
359///
360/// # Response Status
361/// - `200 OK`: Workflow found and returned
362/// - `404 Not Found`: Workflow not found or invalid name
363/// - `500 Internal Server Error`: Failed to read workflow
364///
365/// # Example
366/// ```bash
367/// curl http://localhost:3000/bamboo/workflows/myworkflow
368/// ```
369pub async fn get_workflow(
370    app_state: web::Data<AppState>,
371    workflow_name: web::Path<String>,
372) -> Result<HttpResponse, AppError> {
373    let name = workflow_name.into_inner();
374    if !is_safe_workflow_name(&name) {
375        return Err(AppError::NotFound("Workflow".to_string()));
376    }
377
378    let dir = app_state.app_data_dir.join("workflows");
379    fs::create_dir_all(&dir).await?;
380
381    let filename = format!("{name}.md");
382    let file_path = dir.join(&filename);
383
384    let metadata = match fs::metadata(&file_path).await {
385        Ok(m) => m,
386        Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
387            return Err(AppError::NotFound(format!("Workflow '{name}'")))
388        }
389        Err(e) => return Err(AppError::StorageError(e)),
390    };
391
392    let content = fs::read_to_string(&file_path).await?;
393
394    Ok(HttpResponse::Ok().json(WorkflowGetResponse {
395        name,
396        filename,
397        content,
398        size: metadata.len(),
399        modified_at: None,
400    }))
401}
402
403/// Request body for saving a workflow
404#[derive(Deserialize)]
405pub struct SaveWorkflowRequest {
406    /// Workflow name
407    name: String,
408    /// Workflow markdown content
409    content: String,
410}
411
412/// Creates or updates a workflow
413///
414/// # HTTP Route
415/// `POST /bamboo/workflows`
416///
417/// # Request Body
418/// ```json
419/// {
420///   "name": "myworkflow",
421///   "content": "# My Workflow\n\nStep 1: ..."
422/// }
423/// ```
424///
425/// # Response Format
426/// ```json
427/// {
428///   "success": true,
429///   "path": "/path/to/workflows/myworkflow.md"
430/// }
431/// ```
432///
433/// # Response Status
434/// - `200 OK`: Workflow saved successfully
435/// - `400 Bad Request`: Invalid workflow name
436/// - `500 Internal Server Error`: Failed to save workflow
437///
438/// # Example
439/// ```bash
440/// curl -X POST http://localhost:3000/bamboo/workflows \
441///   -H "Content-Type: application/json" \
442///   -d '{"name": "myworkflow", "content": "# My Workflow"}'
443/// ```
444pub async fn save_workflow(
445    app_state: web::Data<AppState>,
446    payload: web::Json<SaveWorkflowRequest>,
447) -> Result<HttpResponse, AppError> {
448    let name = payload.name.trim();
449    if !is_safe_workflow_name(name) {
450        return Err(AppError::BadRequest("Invalid workflow name".to_string()));
451    }
452
453    let dir = app_state.app_data_dir.join("workflows");
454    fs::create_dir_all(&dir).await?;
455
456    let file_path = dir.join(format!("{}.md", name));
457    fs::write(&file_path, &payload.content).await?;
458
459    Ok(HttpResponse::Ok().json(serde_json::json!({
460        "success": true,
461        "path": file_path.to_string_lossy()
462    })))
463}
464
465/// Deletes a workflow file
466///
467/// # HTTP Route
468/// `DELETE /bamboo/workflows/{name}`
469///
470/// # Path Parameters
471/// - `name`: Workflow name to delete
472///
473/// # Response Format
474/// ```json
475/// {
476///   "success": true
477/// }
478/// ```
479///
480/// # Response Status
481/// - `200 OK`: Workflow deleted successfully
482/// - `400 Bad Request`: Invalid workflow name
483/// - `404 Not Found`: Workflow not found
484/// - `500 Internal Server Error`: Failed to delete workflow
485///
486/// # Example
487/// ```bash
488/// curl -X DELETE http://localhost:3000/bamboo/workflows/myworkflow
489/// ```
490pub async fn delete_workflow(
491    app_state: web::Data<AppState>,
492    workflow_name: web::Path<String>,
493) -> Result<HttpResponse, AppError> {
494    let name = workflow_name.into_inner();
495    if !is_safe_workflow_name(&name) {
496        return Err(AppError::BadRequest("Invalid workflow name".to_string()));
497    }
498
499    let dir = app_state.app_data_dir.join("workflows");
500    let file_path = dir.join(format!("{}.md", name));
501
502    if !file_path.exists() {
503        return Err(AppError::NotFound(format!("Workflow '{}'", name)));
504    }
505
506    fs::remove_file(&file_path).await?;
507
508    Ok(HttpResponse::Ok().json(serde_json::json!({ "success": true })))
509}
510
511// ============================================================================
512// Setup Status Handlers
513// ============================================================================
514
515/// Setup status response
516#[derive(Serialize)]
517struct SetupStatus {
518    /// Whether setup is complete
519    is_complete: bool,
520    /// Whether proxy config exists in config.json
521    has_proxy_config: bool,
522    /// Whether proxy env vars are detected
523    has_proxy_env: bool,
524    /// Status message
525    message: String,
526}
527
528/// Checks if proxy configuration exists in config
529fn has_proxy_config(config: &Value) -> bool {
530    config
531        .get("http_proxy")
532        .and_then(|value| value.as_str())
533        .is_some_and(|value| !value.trim().is_empty())
534        || config
535            .get("https_proxy")
536            .and_then(|value| value.as_str())
537            .is_some_and(|value| !value.trim().is_empty())
538}
539
540fn collect_proxy_environment_flags() -> Vec<&'static str> {
541    ["HTTP_PROXY", "HTTPS_PROXY", "http_proxy", "https_proxy"]
542        .iter()
543        .copied()
544        .filter(|key| {
545            std::env::var(key)
546                .ok()
547                .map(|value| !value.trim().is_empty())
548                .unwrap_or(false)
549        })
550        .collect()
551}
552
553fn is_setup_completed_from_typed(config: &Config) -> bool {
554    config
555        .extra
556        .get("setup")
557        .and_then(|setup| setup.get("completed"))
558        .and_then(|value| value.as_bool())
559        .unwrap_or(false)
560}
561
562async fn persist_app_config(app_state: &AppState) -> Result<(), AppError> {
563    app_state
564        .persist_config()
565        .await
566        .map_err(|e| AppError::InternalError(anyhow::anyhow!("Failed to save config: {e}")))
567}
568
569fn deep_merge_json(dst: &mut Value, src: Value) {
570    match (dst, src) {
571        (Value::Object(dst_obj), Value::Object(src_obj)) => {
572            for (k, v) in src_obj {
573                deep_merge_json(dst_obj.entry(k).or_insert(Value::Null), v);
574            }
575        }
576        (dst_slot, src_val) => {
577            *dst_slot = src_val;
578        }
579    }
580}
581
582fn should_show_setup(setup_completed: bool, _has_proxy_config: bool, _has_proxy_env: bool) -> bool {
583    !setup_completed
584}
585
586fn setup_status_message(
587    setup_completed: bool,
588    has_proxy_config: bool,
589    proxy_environment_flags: &[&str],
590) -> String {
591    if setup_completed {
592        return "Setup has already been completed in config.json.".to_string();
593    }
594
595    if has_proxy_config {
596        return "Proxy configuration already exists in config.json. Setup is not required."
597            .to_string();
598    }
599
600    if !proxy_environment_flags.is_empty() {
601        return format!(
602            "Detected proxy environment variables: {}. Please confirm proxy settings in setup.",
603            proxy_environment_flags.join(", ")
604        );
605    }
606
607    "No proxy configuration or proxy environment variables detected. Setup is not required."
608        .to_string()
609}
610
611/// Gets the setup completion status
612///
613/// # HTTP Route
614/// `GET /bamboo/setup/status`
615///
616/// # Response Format
617/// ```json
618/// {
619///   "is_complete": true,
620///   "has_proxy_config": false,
621///   "has_proxy_env": false,
622///   "message": "Setup has already been completed in config.json."
623/// }
624/// ```
625///
626/// # Response Status
627/// - `200 OK`: Status retrieved successfully
628///
629/// # Example
630/// ```bash
631/// curl http://localhost:3000/bamboo/setup/status
632/// ```
633pub async fn get_setup_status(app_state: web::Data<AppState>) -> Result<HttpResponse, AppError> {
634    let config = app_state.config.read().await.clone();
635    let config_value = serde_json::to_value(&config)?;
636    let has_proxy_config = has_proxy_config(&config_value);
637    let proxy_environment_flags = collect_proxy_environment_flags();
638    let has_proxy_env = !proxy_environment_flags.is_empty();
639    let setup_completed = is_setup_completed_from_typed(&config);
640
641    let is_complete = !should_show_setup(setup_completed, has_proxy_config, has_proxy_env);
642    let message = setup_status_message(setup_completed, has_proxy_config, &proxy_environment_flags);
643
644    Ok(HttpResponse::Ok().json(SetupStatus {
645        is_complete,
646        has_proxy_config,
647        has_proxy_env,
648        message,
649    }))
650}
651
652/// Marks the setup as complete
653///
654/// # HTTP Route
655/// `POST /bamboo/setup/complete`
656///
657/// # Response Format
658/// ```json
659/// {
660///   "success": true
661/// }
662/// ```
663///
664/// # Response Status
665/// - `200 OK`: Setup marked as complete
666/// - `500 Internal Server Error`: Failed to update config
667///
668/// # Example
669/// ```bash
670/// curl -X POST http://localhost:3000/bamboo/setup/complete
671/// ```
672pub async fn mark_setup_complete(app_state: web::Data<AppState>) -> Result<HttpResponse, AppError> {
673    let completed_at = Utc::now().to_rfc3339_opts(SecondsFormat::Secs, true);
674    let config_to_save = {
675        let mut config = app_state.config.write().await;
676        let setup_entry = config
677            .extra
678            .entry("setup".to_string())
679            .or_insert_with(|| serde_json::json!({}));
680        let setup_obj = setup_entry.as_object_mut().ok_or_else(|| {
681            AppError::BadRequest("config.setup must be a JSON object".to_string())
682        })?;
683
684        setup_obj.insert("completed".to_string(), Value::Bool(true));
685        setup_obj.insert("completed_at".to_string(), Value::String(completed_at));
686        setup_obj.insert("version".to_string(), Value::Number(1.into()));
687
688        config.clone()
689    };
690
691    let _ = config_to_save;
692    persist_app_config(app_state.get_ref()).await?;
693
694    Ok(HttpResponse::Ok().json(serde_json::json!({ "success": true })))
695}
696
697/// Resets setup status to incomplete
698///
699/// # HTTP Route
700/// `POST /bamboo/setup/incomplete`
701///
702/// # Response Format
703/// ```json
704/// {
705///   "success": true
706/// }
707/// ```
708///
709/// # Response Status
710/// - `200 OK`: Setup marked as incomplete
711/// - `500 Internal Server Error`: Failed to update config
712///
713/// # Example
714/// ```bash
715/// curl -X POST http://localhost:3000/bamboo/setup/incomplete
716/// ```
717pub async fn mark_setup_incomplete(
718    app_state: web::Data<AppState>,
719) -> Result<HttpResponse, AppError> {
720    let reset_at = Utc::now().to_rfc3339_opts(SecondsFormat::Secs, true);
721    let config_to_save = {
722        let mut config = app_state.config.write().await;
723        if let Some(setup_entry) = config.extra.get_mut("setup") {
724            if let Some(setup_obj) = setup_entry.as_object_mut() {
725                setup_obj.insert("completed".to_string(), Value::Bool(false));
726                setup_obj.insert("reset_at".to_string(), Value::String(reset_at));
727            }
728        }
729        config.clone()
730    };
731
732    let _ = config_to_save;
733    persist_app_config(app_state.get_ref()).await?;
734
735    Ok(HttpResponse::Ok().json(serde_json::json!({ "success": true })))
736}
737
738// ============================================================================
739// Configuration Handlers
740// ============================================================================
741
742/// Gets the Bamboo application configuration
743///
744/// # HTTP Route
745/// `GET /bamboo/config`
746///
747/// # Response Format
748/// Returns the config.json contents (with sensitive fields removed):
749/// ```json
750/// {
751///   "provider": "copilot",
752///   "http_proxy": "http://proxy:8080",
753///   "providers": {...}
754/// }
755/// ```
756///
757/// # Response Status
758/// - `200 OK`: Config retrieved successfully (empty object if not found)
759///
760/// # Security
761/// Proxy authentication credentials are stripped from the response.
762///
763/// # Example
764/// ```bash
765/// curl http://localhost:3000/bamboo/config
766/// ```
767pub async fn get_bamboo_config(app_state: web::Data<AppState>) -> Result<HttpResponse, AppError> {
768    let path = app_state.app_data_dir.join("config.json");
769    if !path.exists() {
770        return Ok(HttpResponse::Ok().json(serde_json::json!({})));
771    }
772
773    let mut config = app_state.config.read().await.clone();
774    config.refresh_proxy_auth_encrypted()?;
775    config.refresh_provider_api_keys_encrypted()?;
776    let value = serde_json::to_value(&config)?;
777    Ok(HttpResponse::Ok().json(redact_config_for_api(value, &config)))
778}
779
780/// Updates the Bamboo application configuration
781///
782/// # HTTP Route
783/// `POST /bamboo/config`
784///
785/// # Request Body
786/// Configuration JSON object:
787/// ```json
788/// {
789///   "provider": "openai",
790///   "http_proxy": "http://proxy:8080",
791///   "providers": {
792///     "openai": {
793///       "api_key": "sk-..."
794///     }
795///   }
796/// }
797/// ```
798///
799/// # Response Format
800/// Returns the saved config (with sensitive fields removed):
801/// ```json
802/// {
803///   "provider": "openai",
804///   ...
805/// }
806/// ```
807///
808/// # Response Status
809/// - `200 OK`: Config saved successfully
810/// - `500 Internal Server Error`: Failed to save config
811///
812/// # Security
813/// Proxy auth fields are automatically encrypted before saving.
814///
815/// # Example
816/// ```bash
817/// curl -X POST http://localhost:3000/bamboo/config \
818///   -H "Content-Type: application/json" \
819///   -d '{"provider": "openai"}'
820/// ```
821pub async fn set_bamboo_config(
822    app_state: web::Data<AppState>,
823    payload: web::Json<Value>,
824) -> Result<HttpResponse, AppError> {
825    let mut patch = payload.into_inner();
826    let patch_obj = patch
827        .as_object_mut()
828        .ok_or_else(|| AppError::BadRequest("config.json must be a JSON object".to_string()))?;
829
830    // Never allow clients to modify proxy auth fields or data_dir via this endpoint.
831    patch_obj.remove("proxy_auth");
832    patch_obj.remove("proxy_auth_encrypted");
833    patch_obj.remove("data_dir");
834
835    // Never allow clients to set encrypted secret material directly.
836    if let Some(servers) = patch
837        .get_mut("mcp")
838        .and_then(|m| m.get_mut("servers"))
839        .and_then(|v| v.as_array_mut())
840    {
841        for server in servers.iter_mut() {
842            let Some(server_obj) = server.as_object_mut() else {
843                continue;
844            };
845            let Some(transport) = server_obj
846                .get_mut("transport")
847                .and_then(|v| v.as_object_mut())
848            else {
849                continue;
850            };
851
852            match transport.get("type").and_then(|v| v.as_str()) {
853                Some("stdio") => {
854                    transport.remove("env_encrypted");
855                }
856                Some("sse") => {
857                    if let Some(headers) =
858                        transport.get_mut("headers").and_then(|v| v.as_array_mut())
859                    {
860                        for header in headers.iter_mut() {
861                            let Some(header_obj) = header.as_object_mut() else {
862                                continue;
863                            };
864                            header_obj.remove("value_encrypted");
865                        }
866                    }
867                }
868                _ => {}
869            }
870        }
871    }
872
873    let current = app_state.config.read().await.clone();
874    let mut merged = serde_json::to_value(&current)?;
875
876    // Preserve existing API keys when the client sends masked placeholders.
877    if let Some(patch_providers) = patch.get_mut("providers").and_then(|v| v.as_object_mut()) {
878        for (provider_name, provider_patch) in patch_providers.iter_mut() {
879            let Some(patch_cfg_obj) = provider_patch.as_object_mut() else {
880                continue;
881            };
882
883            // Do not allow clients to directly set encrypted key material.
884            patch_cfg_obj.remove("api_key_encrypted");
885
886            let Some(api_key) = patch_cfg_obj.get("api_key").and_then(|v| v.as_str()) else {
887                continue;
888            };
889            if !is_masked_api_key(api_key) {
890                continue;
891            }
892
893            let existing_plain = match provider_name.as_str() {
894                "openai" => current.providers.openai.as_ref().map(|c| c.api_key.clone()),
895                "anthropic" => current
896                    .providers
897                    .anthropic
898                    .as_ref()
899                    .map(|c| c.api_key.clone()),
900                "gemini" => current.providers.gemini.as_ref().map(|c| c.api_key.clone()),
901                _ => None,
902            };
903
904            if let Some(existing_plain) = existing_plain {
905                if !existing_plain.trim().is_empty() {
906                    patch_cfg_obj.insert("api_key".to_string(), Value::String(existing_plain));
907                } else {
908                    patch_cfg_obj.remove("api_key");
909                }
910            } else {
911                patch_cfg_obj.remove("api_key");
912            }
913        }
914    }
915
916    deep_merge_json(&mut merged, patch);
917
918    let mut new_config: Config = serde_json::from_value(merged)?;
919    new_config.data_dir = app_state.app_data_dir.clone();
920    new_config.hydrate_proxy_auth_from_encrypted();
921    new_config.hydrate_provider_api_keys_from_encrypted();
922
923    // Validate before persisting.
924    if let Err(e) = crate::agent::llm::validate_provider_config(&new_config) {
925        return Err(AppError::BadRequest(format!("Invalid configuration: {e}")));
926    }
927
928    // Update in-memory config first, then persist and reload provider.
929    {
930        let mut cfg = app_state.config.write().await;
931        *cfg = new_config.clone();
932    }
933    persist_app_config(app_state.get_ref()).await?;
934    app_state.reload_provider().await.map_err(|e| {
935        AppError::InternalError(anyhow::anyhow!(
936            "Failed to reload provider after updating config: {e}"
937        ))
938    })?;
939
940    let mut config_for_response = new_config.clone();
941    config_for_response.refresh_proxy_auth_encrypted()?;
942    config_for_response.refresh_provider_api_keys_encrypted()?;
943    let value = serde_json::to_value(&config_for_response)?;
944    Ok(HttpResponse::Ok().json(redact_config_for_api(value, &config_for_response)))
945}
946
947/// Request body for setting proxy authentication
948#[derive(Deserialize)]
949pub struct ProxyAuthPayload {
950    /// Proxy username
951    username: Option<String>,
952    /// Proxy password
953    password: Option<String>,
954}
955
956/// Sets proxy authentication credentials
957///
958/// # HTTP Route
959/// `POST /bamboo/proxy-auth`
960///
961/// # Request Body
962/// ```json
963/// {
964///   "username": "user",
965///   "password": "pass"
966/// }
967/// ```
968///
969/// # Response Format
970/// ```json
971/// {
972///   "success": true
973/// }
974/// ```
975///
976/// # Response Status
977/// - `200 OK`: Proxy auth saved and provider reloaded
978/// - `500 Internal Server Error`: Failed to save or reload
979///
980/// # Security
981/// Credentials are encrypted before storage in config.json.
982///
983/// # Example
984/// ```bash
985/// curl -X POST http://localhost:3000/bamboo/proxy-auth \
986///   -H "Content-Type: application/json" \
987///   -d '{"username": "user", "password": "pass"}'
988/// ```
989pub async fn set_proxy_auth(
990    app_state: web::Data<AppState>,
991    payload: web::Json<ProxyAuthPayload>,
992) -> Result<HttpResponse, AppError> {
993    let username = payload.username.clone().unwrap_or_default();
994    let password = payload.password.clone().unwrap_or_default();
995
996    // Store proxy auth in config
997    let auth = if username.trim().is_empty() {
998        None
999    } else {
1000        Some(ProxyAuth { username, password })
1001    };
1002
1003    let config_to_save = {
1004        let mut config = app_state.config.write().await;
1005        config.proxy_auth = auth;
1006        config.refresh_proxy_auth_encrypted()?;
1007        config.clone()
1008    };
1009
1010    let _ = config_to_save;
1011    persist_app_config(app_state.get_ref()).await?;
1012
1013    // Reload provider to apply new proxy settings
1014    app_state.reload_provider().await.map_err(|e| {
1015        AppError::InternalError(anyhow::anyhow!(
1016            "Failed to reload provider after updating proxy auth: {e}"
1017        ))
1018    })?;
1019
1020    Ok(HttpResponse::Ok().json(serde_json::json!({ "success": true })))
1021}
1022
1023/// Gets proxy authentication status
1024///
1025/// # HTTP Route
1026/// `GET /bamboo/proxy-auth/status`
1027///
1028/// # Response Format
1029/// ```json
1030/// {
1031///   "configured": true,
1032///   "username": "myuser"
1033/// }
1034/// ```
1035///
1036/// # Response Status
1037/// - `200 OK`: Status retrieved successfully
1038///
1039/// # Note
1040/// Password is never returned, only whether auth is configured and the username.
1041///
1042/// # Example
1043/// ```bash
1044/// curl http://localhost:3000/bamboo/proxy-auth/status
1045/// ```
1046pub async fn get_proxy_auth_status(
1047    app_state: web::Data<AppState>,
1048) -> Result<HttpResponse, AppError> {
1049    let config = app_state.config.read().await;
1050    if let Some(auth) = config.proxy_auth.as_ref() {
1051        return Ok(HttpResponse::Ok().json(serde_json::json!({
1052            "configured": true,
1053            "username": auth.username,
1054        })));
1055    }
1056
1057    Ok(HttpResponse::Ok().json(serde_json::json!({
1058        "configured": false,
1059        "username": serde_json::Value::Null
1060    })))
1061}
1062
1063/// Resets (deletes) the Bamboo configuration file
1064///
1065/// # HTTP Route
1066/// `POST /bamboo/config/reset`
1067///
1068/// # Response Format
1069/// ```json
1070/// {
1071///   "success": true
1072/// }
1073/// ```
1074///
1075/// # Response Status
1076/// - `200 OK`: Config reset successfully
1077/// - `500 Internal Server Error`: Failed to delete config
1078///
1079/// # Warning
1080/// This permanently deletes the config.json file. Use with caution.
1081///
1082/// # Example
1083/// ```bash
1084/// curl -X POST http://localhost:3000/bamboo/config/reset
1085/// ```
1086pub async fn reset_bamboo_config(app_state: web::Data<AppState>) -> Result<HttpResponse, AppError> {
1087    let path = app_state.app_data_dir.join("config.json");
1088    // Try to delete config.json if it exists
1089    match fs::try_exists(&path).await {
1090        Ok(true) => {
1091            fs::remove_file(&path)
1092                .await
1093                .map_err(AppError::StorageError)?;
1094        }
1095        Ok(false) => {
1096            // Config file doesn't exist, nothing to do
1097        }
1098        Err(err) => return Err(AppError::StorageError(err)),
1099    }
1100
1101    // Reset in-memory config and best-effort reload provider.
1102    let new_config = app_state.reload_config().await;
1103    if let Err(e) = app_state.reload_provider().await {
1104        log::warn!(
1105            "Config reset updated config to provider={}, but provider reload failed: {}",
1106            new_config.provider,
1107            e
1108        );
1109    }
1110    Ok(HttpResponse::Ok().json(serde_json::json!({ "success": true })))
1111}
1112
1113pub async fn get_anthropic_model_mapping(
1114    app_state: web::Data<AppState>,
1115) -> Result<HttpResponse, AppError> {
1116    let config = app_state.config.read().await;
1117    Ok(HttpResponse::Ok().json(config.anthropic_model_mapping.clone()))
1118}
1119
1120pub async fn set_anthropic_model_mapping(
1121    app_state: web::Data<AppState>,
1122    payload: web::Json<crate::core::model_mapping::AnthropicModelMapping>,
1123) -> Result<HttpResponse, AppError> {
1124    let mapping = payload.into_inner();
1125    let config_to_save = {
1126        let mut config = app_state.config.write().await;
1127        config.anthropic_model_mapping = mapping.clone();
1128        config.clone()
1129    };
1130    let _ = config_to_save;
1131    persist_app_config(app_state.get_ref()).await?;
1132    Ok(HttpResponse::Ok().json(mapping))
1133}
1134
1135// ============================================================================
1136// Keyword Masking Handlers
1137// ============================================================================
1138
1139/// Response for keyword masking configuration
1140#[derive(Debug, Serialize, Deserialize)]
1141struct KeywordMaskingResponse {
1142    /// List of keyword masking entries
1143    entries: Vec<KeywordEntry>,
1144}
1145
1146/// Validation error for keyword entries
1147#[derive(Debug, Serialize, Deserialize)]
1148struct ValidationError {
1149    /// Index of the invalid entry
1150    index: usize,
1151    /// Error message
1152    message: String,
1153}
1154
1155/// Gets keyword masking configuration
1156///
1157/// # HTTP Route
1158/// `GET /bamboo/keyword-masking`
1159///
1160/// # Response Format
1161/// ```json
1162/// {
1163///   "entries": [
1164///     {
1165///       "pattern": "secret",
1166///       "mask_type": "full",
1167///       "case_sensitive": false
1168///     }
1169///   ]
1170/// }
1171/// ```
1172///
1173/// # Response Status
1174/// - `200 OK`: Config retrieved successfully
1175///
1176/// # Example
1177/// ```bash
1178/// curl http://localhost:3000/bamboo/keyword-masking
1179/// ```
1180pub async fn get_keyword_masking_config(
1181    app_state: web::Data<AppState>,
1182) -> Result<HttpResponse, AppError> {
1183    let config = app_state.config.read().await;
1184    Ok(HttpResponse::Ok().json(KeywordMaskingResponse {
1185        entries: config.keyword_masking.entries.clone(),
1186    }))
1187}
1188
1189/// Updates keyword masking configuration
1190///
1191/// # HTTP Route
1192/// `POST /bamboo/keyword-masking`
1193///
1194/// # Request Body
1195/// Array of keyword entries:
1196/// ```json
1197/// [
1198///   {
1199///     "pattern": "secret",
1200///     "mask_type": "full",
1201///     "case_sensitive": false
1202///   }
1203/// ]
1204/// ```
1205///
1206/// # Response Format
1207/// Returns the saved configuration:
1208/// ```json
1209/// {
1210///   "entries": [...]
1211/// }
1212/// ```
1213///
1214/// # Response Status
1215/// - `200 OK`: Config saved successfully
1216/// - `400 Bad Request`: Validation failed (max 100 entries, max 500 char patterns)
1217/// - `500 Internal Server Error`: Failed to save config
1218///
1219/// # Limits
1220/// - Maximum 100 entries
1221/// - Maximum 500 characters per pattern
1222///
1223/// # Example
1224/// ```bash
1225/// curl -X POST http://localhost:3000/bamboo/keyword-masking \
1226///   -H "Content-Type: application/json" \
1227///   -d '[{"pattern": "secret", "mask_type": "full"}]'
1228/// ```
1229pub async fn update_keyword_masking_config(
1230    app_state: web::Data<AppState>,
1231    payload: web::Json<Vec<KeywordEntry>>,
1232) -> Result<HttpResponse, AppError> {
1233    let entries = payload.into_inner();
1234
1235    // Input validation limits to prevent DoS
1236    const MAX_ENTRIES: usize = 100;
1237    const MAX_PATTERN_LENGTH: usize = 500;
1238
1239    if entries.len() > MAX_ENTRIES {
1240        return Err(AppError::BadRequest(format!(
1241            "Too many entries: {} (max {})",
1242            entries.len(),
1243            MAX_ENTRIES
1244        )));
1245    }
1246
1247    // Validate pattern lengths
1248    for (idx, entry) in entries.iter().enumerate() {
1249        if entry.pattern.len() > MAX_PATTERN_LENGTH {
1250            return Err(AppError::BadRequest(format!(
1251                "Pattern at index {} too long: {} chars (max {})",
1252                idx,
1253                entry.pattern.len(),
1254                MAX_PATTERN_LENGTH
1255            )));
1256        }
1257    }
1258
1259    let config = KeywordMaskingConfig { entries };
1260
1261    // Validate all entries
1262    if let Err(errors) = config.validate() {
1263        let validation_errors: Vec<ValidationError> = errors
1264            .into_iter()
1265            .map(|(idx, msg)| ValidationError {
1266                index: idx,
1267                message: msg,
1268            })
1269            .collect();
1270        return Err(AppError::BadRequest(format!(
1271            "Validation failed: {:?}",
1272            validation_errors
1273        )));
1274    }
1275
1276    let config_to_save = {
1277        let mut current = app_state.config.write().await;
1278        current.keyword_masking = config.clone();
1279        current.clone()
1280    };
1281    let _ = config_to_save;
1282    persist_app_config(app_state.get_ref()).await?;
1283    // Keyword masking is applied via provider decorator; reload to make it effective immediately.
1284    app_state.reload_provider().await.map_err(|e| {
1285        AppError::InternalError(anyhow::anyhow!(
1286            "Failed to reload provider after updating keyword masking: {e}"
1287        ))
1288    })?;
1289
1290    Ok(HttpResponse::Ok().json(KeywordMaskingResponse {
1291        entries: config.entries,
1292    }))
1293}
1294
1295/// Validates keyword masking entries without saving
1296///
1297/// # HTTP Route
1298/// `POST /bamboo/keyword-masking/validate`
1299///
1300/// # Request Body
1301/// Array of keyword entries to validate
1302///
1303/// # Response Format
1304/// Success:
1305/// ```json
1306/// {
1307///   "valid": true
1308/// }
1309/// ```
1310///
1311/// Validation errors:
1312/// ```json
1313/// {
1314///   "valid": false,
1315///   "errors": [
1316///     {
1317///       "index": 0,
1318///       "message": "Pattern cannot be empty"
1319///     }
1320///   ]
1321/// }
1322/// ```
1323///
1324/// # Response Status
1325/// - `200 OK`: Validation completed (check `valid` field for result)
1326///
1327/// # Example
1328/// ```bash
1329/// curl -X POST http://localhost:3000/bamboo/keyword-masking/validate \
1330///   -H "Content-Type: application/json" \
1331///   -d '[{"pattern": "test", "mask_type": "full"}]'
1332/// ```
1333pub async fn validate_keyword_entries(
1334    payload: web::Json<Vec<KeywordEntry>>,
1335) -> Result<HttpResponse, AppError> {
1336    let entries = payload.into_inner();
1337    let config = KeywordMaskingConfig { entries };
1338
1339    match config.validate() {
1340        Ok(()) => Ok(HttpResponse::Ok().json(serde_json::json!({ "valid": true }))),
1341        Err(errors) => {
1342            let validation_errors: Vec<ValidationError> = errors
1343                .into_iter()
1344                .map(|(idx, msg)| ValidationError {
1345                    index: idx,
1346                    message: msg,
1347                })
1348                .collect();
1349            Ok(HttpResponse::Ok().json(serde_json::json!({
1350                "valid": false,
1351                "errors": validation_errors
1352            })))
1353        }
1354    }
1355}
1356
1357// ============================================================================
1358// Provider Configuration Handlers
1359// ============================================================================
1360
1361/// Response for provider configuration
1362#[derive(Serialize)]
1363struct ProviderConfigResponse {
1364    /// Currently active provider
1365    provider: String,
1366    /// List of available provider types
1367    available_providers: Vec<String>,
1368    /// Provider-specific configurations (API keys masked)
1369    providers: Value,
1370}
1371
1372/// Request body for updating provider configuration
1373#[derive(Deserialize)]
1374pub struct UpdateProviderRequest {
1375    /// Provider to activate
1376    provider: String,
1377    /// Provider-specific configurations
1378    #[serde(default)]
1379    providers: Value,
1380}
1381
1382/// Gets current provider configuration with API keys masked
1383///
1384/// # HTTP Route
1385/// `GET /bamboo/settings/provider`
1386///
1387/// # Response Format
1388/// ```json
1389/// {
1390///   "provider": "openai",
1391///   "available_providers": ["copilot", "openai", "anthropic", "gemini"],
1392///   "providers": {
1393///     "openai": {
1394///       "api_key": "****...****",
1395///       "model": "gpt-4"
1396///     }
1397///   }
1398/// }
1399/// ```
1400///
1401/// # Response Status
1402/// - `200 OK`: Configuration retrieved successfully
1403///
1404/// # Security
1405/// API keys are masked to prevent exposure.
1406///
1407/// # Example
1408/// ```bash
1409/// curl http://localhost:3000/bamboo/settings/provider
1410/// ```
1411pub async fn get_provider_config(app_state: web::Data<AppState>) -> Result<HttpResponse, AppError> {
1412    let mut config = app_state.config.read().await.clone();
1413    let provider = config.provider.clone();
1414    config.refresh_provider_api_keys_encrypted()?;
1415    let providers = serde_json::to_value(&config.providers)?;
1416    let masked_providers = redact_providers_for_api(providers, &config);
1417
1418    let response = ProviderConfigResponse {
1419        provider,
1420        available_providers: AVAILABLE_PROVIDERS.iter().map(|s| s.to_string()).collect(),
1421        providers: masked_providers,
1422    };
1423
1424    Ok(HttpResponse::Ok().json(response))
1425}
1426
1427/// Updates provider configuration and reloads the provider
1428///
1429/// # HTTP Route
1430/// `POST /bamboo/settings/provider`
1431///
1432/// # Request Body
1433/// ```json
1434/// {
1435///   "provider": "openai",
1436///   "providers": {
1437///     "openai": {
1438///       "api_key": "sk-...",
1439///       "model": "gpt-4"
1440///     }
1441///   }
1442/// }
1443/// ```
1444///
1445/// # Response Format
1446/// Success:
1447/// ```json
1448/// {
1449///   "success": true,
1450///   "provider": "openai"
1451/// }
1452/// ```
1453///
1454/// Error:
1455/// ```json
1456/// {
1457///   "success": false,
1458///   "error": "Configuration saved but invalid: ..."
1459/// }
1460/// ```
1461///
1462/// # Response Status
1463/// - `200 OK`: Configuration updated (check `success` field)
1464/// - `400 Bad Request`: Invalid configuration
1465/// - `500 Internal Server Error`: Failed to save or reload
1466///
1467/// # Features
1468/// - Preserves existing API keys if masked values are sent
1469/// - Validates configuration before applying
1470/// - Automatically reloads provider (no separate reload call required)
1471///
1472/// # Example
1473/// ```bash
1474/// curl -X POST http://localhost:3000/bamboo/settings/provider \
1475///   -H "Content-Type: application/json" \
1476///   -d '{"provider": "openai", "providers": {"openai": {"api_key": "sk-..."}}}'
1477/// ```
1478pub async fn update_provider_config(
1479    app_state: web::Data<AppState>,
1480    payload: web::Json<UpdateProviderRequest>,
1481) -> Result<HttpResponse, AppError> {
1482    let current = app_state.config.read().await.clone();
1483    let mut merged = serde_json::to_value(&current)?;
1484
1485    // Build a patch like { provider: "...", providers: { ... } } and preserve existing
1486    // API keys when the client sends masked placeholders.
1487    let mut patch = serde_json::json!({
1488        "provider": payload.provider,
1489        "providers": payload.providers,
1490    });
1491
1492    if let Some(patch_providers) = patch.get_mut("providers").and_then(|v| v.as_object_mut()) {
1493        for (provider_name, provider_patch) in patch_providers.iter_mut() {
1494            let Some(patch_cfg_obj) = provider_patch.as_object_mut() else {
1495                continue;
1496            };
1497
1498            // Do not allow clients to directly set encrypted key material.
1499            patch_cfg_obj.remove("api_key_encrypted");
1500
1501            let Some(api_key) = patch_cfg_obj.get("api_key").and_then(|v| v.as_str()) else {
1502                continue;
1503            };
1504            if !is_masked_api_key(api_key) {
1505                continue;
1506            }
1507
1508            let existing_plain = match provider_name.as_str() {
1509                "openai" => current.providers.openai.as_ref().map(|c| c.api_key.clone()),
1510                "anthropic" => current
1511                    .providers
1512                    .anthropic
1513                    .as_ref()
1514                    .map(|c| c.api_key.clone()),
1515                "gemini" => current.providers.gemini.as_ref().map(|c| c.api_key.clone()),
1516                _ => None,
1517            };
1518
1519            if let Some(existing_plain) = existing_plain {
1520                if !existing_plain.trim().is_empty() {
1521                    patch_cfg_obj.insert("api_key".to_string(), Value::String(existing_plain));
1522                } else {
1523                    patch_cfg_obj.remove("api_key");
1524                }
1525            } else {
1526                patch_cfg_obj.remove("api_key");
1527            }
1528        }
1529    }
1530
1531    deep_merge_json(&mut merged, patch);
1532
1533    let mut new_config: Config = serde_json::from_value(merged)?;
1534    new_config.data_dir = app_state.app_data_dir.clone();
1535    new_config.hydrate_proxy_auth_from_encrypted();
1536    new_config.hydrate_provider_api_keys_from_encrypted();
1537
1538    if let Err(e) = crate::agent::llm::validate_provider_config(&new_config) {
1539        return Ok(HttpResponse::BadRequest().json(serde_json::json!({
1540            "success": false,
1541            "error": format!("Invalid configuration: {}", e)
1542        })));
1543    }
1544
1545    {
1546        let mut cfg = app_state.config.write().await;
1547        *cfg = new_config.clone();
1548    }
1549    persist_app_config(app_state.get_ref()).await?;
1550    app_state.reload_provider().await.map_err(|e| {
1551        AppError::InternalError(anyhow::anyhow!(
1552            "Failed to reload provider after updating configuration: {e}"
1553        ))
1554    })?;
1555
1556    Ok(HttpResponse::Ok().json(serde_json::json!({
1557        "success": true,
1558        "provider": new_config.provider
1559    })))
1560}
1561
1562/// Fetch available models for a specific provider
1563pub async fn fetch_provider_models(
1564    app_state: web::Data<AppState>,
1565    payload: web::Json<serde_json::Value>,
1566) -> Result<HttpResponse, AppError> {
1567    let config = app_state.config.read().await.clone();
1568    let provider_type = payload
1569        .get("provider")
1570        .and_then(|v| v.as_str())
1571        .unwrap_or(config.provider.as_str());
1572
1573    // Build a proxy-aware HTTP client for all outbound calls.
1574    let client = crate::agent::llm::http_client::build_http_client(&config).map_err(|e| {
1575        AppError::InternalError(anyhow::anyhow!("Failed to build HTTP client: {e}"))
1576    })?;
1577
1578    let models =
1579        match provider_type {
1580            "copilot" => {
1581                let provider = app_state.get_provider().await;
1582                provider.list_models().await.map_err(|e| {
1583                    let msg = e.to_string();
1584                    if msg.contains("proxy") || msg.contains("407") {
1585                        AppError::ProxyAuthRequired
1586                    } else {
1587                        AppError::InternalError(anyhow::anyhow!("Failed to fetch models: {e}"))
1588                    }
1589                })?
1590            }
1591            "openai" => {
1592                let openai = config.providers.openai.as_ref().ok_or_else(|| {
1593                    AppError::BadRequest("OpenAI configuration required".to_string())
1594                })?;
1595                if openai.api_key.trim().is_empty() {
1596                    return Err(AppError::BadRequest("API key not configured".to_string()));
1597                }
1598                fetch_models_from_api(
1599                    &client,
1600                    "openai",
1601                    &openai.api_key,
1602                    openai.base_url.as_deref(),
1603                )
1604                .await?
1605            }
1606            "anthropic" => {
1607                let anthropic = config.providers.anthropic.as_ref().ok_or_else(|| {
1608                    AppError::BadRequest("Anthropic configuration required".to_string())
1609                })?;
1610                if anthropic.api_key.trim().is_empty() {
1611                    return Err(AppError::BadRequest("API key not configured".to_string()));
1612                }
1613                fetch_models_from_api(
1614                    &client,
1615                    "anthropic",
1616                    &anthropic.api_key,
1617                    anthropic.base_url.as_deref(),
1618                )
1619                .await?
1620            }
1621            "gemini" => {
1622                let gemini = config.providers.gemini.as_ref().ok_or_else(|| {
1623                    AppError::BadRequest("Gemini configuration required".to_string())
1624                })?;
1625                if gemini.api_key.trim().is_empty() {
1626                    return Err(AppError::BadRequest("API key not configured".to_string()));
1627                }
1628                fetch_models_from_api(
1629                    &client,
1630                    "gemini",
1631                    &gemini.api_key,
1632                    gemini.base_url.as_deref(),
1633                )
1634                .await?
1635            }
1636            other => {
1637                return Err(AppError::BadRequest(format!(
1638                    "Unsupported provider: {other}"
1639                )));
1640            }
1641        };
1642
1643    Ok(HttpResponse::Ok().json(serde_json::json!({ "models": models })))
1644}
1645
1646/// Fetch models from external API
1647async fn fetch_models_from_api(
1648    client: &reqwest::Client,
1649    provider: &str,
1650    api_key: &str,
1651    base_url: Option<&str>,
1652) -> Result<Vec<String>, AppError> {
1653    let (url, auth_header, use_query_param) = match provider {
1654        "openai" => {
1655            let url = if let Some(base) = base_url {
1656                let base = base.trim_end_matches('/');
1657                format!("{}/models", base)
1658            } else {
1659                "https://api.openai.com/v1/models".to_string()
1660            };
1661            (url, format!("Bearer {}", api_key), false)
1662        }
1663        "anthropic" => {
1664            let url = if let Some(base) = base_url {
1665                let base = base.trim_end_matches('/');
1666                format!("{}/models", base)
1667            } else {
1668                "https://api.anthropic.com/v1/models".to_string()
1669            };
1670            (url, api_key.to_string(), false) // Anthropic uses x-api-key header
1671        }
1672        "gemini" => {
1673            let url = if let Some(base) = base_url {
1674                let base = base.trim_end_matches('/');
1675                format!("{}?key={}", base, api_key)
1676            } else {
1677                format!(
1678                    "https://generativelanguage.googleapis.com/v1beta/models?key={}",
1679                    api_key
1680                )
1681            };
1682            (url, String::new(), true) // Gemini uses query param for auth
1683        }
1684        _ => {
1685            return Err(AppError::BadRequest(format!(
1686                "Unsupported provider: {}",
1687                provider
1688            )));
1689        }
1690    };
1691
1692    log::info!("Fetching models from: {}", url);
1693
1694    let mut request = client.get(&url);
1695
1696    // Set appropriate authentication header based on provider (not for Gemini)
1697    if !use_query_param {
1698        if provider == "anthropic" {
1699            request = request.header("x-api-key", auth_header);
1700        } else {
1701            request = request.header("Authorization", auth_header);
1702        }
1703    }
1704
1705    let response = request
1706        .send()
1707        .await
1708        .map_err(|e| AppError::InternalError(anyhow::anyhow!("Request failed: {}", e)))?;
1709
1710    if !response.status().is_success() {
1711        let status = response.status();
1712        let error_text = response.text().await.unwrap_or_default();
1713        return Err(AppError::InternalError(anyhow::anyhow!(
1714            "API request failed: {} - {}",
1715            status,
1716            error_text
1717        )));
1718    }
1719
1720    let json: Value = response
1721        .json()
1722        .await
1723        .map_err(|e| AppError::InternalError(anyhow::anyhow!("Failed to parse JSON: {}", e)))?;
1724
1725    // Extract model IDs from different response formats
1726    let models: Vec<String> = if let Some(data) = json.get("data").and_then(|d| d.as_array()) {
1727        // Standard OpenAI format
1728        data.iter()
1729            .filter_map(|model| {
1730                model
1731                    .get("id")
1732                    .and_then(|id| id.as_str())
1733                    .map(|s| s.to_string())
1734            })
1735            .collect()
1736    } else if let Some(models_arr) = json.get("models").and_then(|m| m.as_array()) {
1737        // Alternative format: { models: [...] } - Gemini uses this
1738        models_arr
1739            .iter()
1740            .filter_map(|model| {
1741                // Gemini models have "name" field
1742                if let Some(name) = model.get("name").and_then(|n| n.as_str()) {
1743                    Some(name.to_string())
1744                } else if let Some(id) = model.get("id").and_then(|i| i.as_str()) {
1745                    Some(id.to_string())
1746                } else {
1747                    model.as_str().map(|s| s.to_string())
1748                }
1749            })
1750            .collect()
1751    } else if let Some(arr) = json.as_array() {
1752        // Direct array format
1753        arr.iter()
1754            .filter_map(|v| v.as_str().map(|s| s.to_string()))
1755            .collect()
1756    } else {
1757        return Err(AppError::InternalError(anyhow::anyhow!(
1758            "Unexpected response format"
1759        )));
1760    };
1761
1762    log::info!("Fetched {} models", models.len());
1763    Ok(models)
1764}
1765
1766/// Reloads provider configuration from file and recreates the provider
1767///
1768/// # HTTP Route
1769/// `POST /bamboo/settings/reload`
1770///
1771/// # Response Format
1772/// Success:
1773/// ```json
1774/// {
1775///   "success": true,
1776///   "provider": "openai"
1777/// }
1778/// ```
1779///
1780/// Error:
1781/// ```json
1782/// {
1783///   "success": false,
1784///   "error": "Invalid configuration: ..."
1785/// }
1786/// ```
1787///
1788/// # Response Status
1789/// - `200 OK`: Reload completed (check `success` field)
1790/// - `400 Bad Request`: Invalid configuration
1791/// - `500 Internal Server Error`: Failed to reload provider
1792///
1793/// # Example
1794/// ```bash
1795/// curl -X POST http://localhost:3000/bamboo/settings/reload
1796/// ```
1797///
1798/// # Notes
1799/// In most cases you should not need to call this endpoint, because
1800/// `POST /bamboo/settings/provider` already saves the config and reloads the provider.
1801pub async fn reload_provider_config(
1802    app_state: web::Data<AppState>,
1803) -> Result<HttpResponse, AppError> {
1804    // First, reload the configuration from file into AppState
1805    let new_config = app_state.reload_config().await;
1806
1807    // Validate the configuration
1808    if let Err(e) = crate::agent::llm::validate_provider_config(&new_config) {
1809        return Ok(HttpResponse::BadRequest().json(serde_json::json!({
1810            "success": false,
1811            "error": e.to_string()
1812        })));
1813    }
1814
1815    // Reload the provider in AppState using the updated config
1816    if let Err(e) = app_state.reload_provider().await {
1817        return Ok(HttpResponse::InternalServerError().json(serde_json::json!({
1818            "success": false,
1819            "error": format!("Failed to reload provider: {}", e)
1820        })));
1821    }
1822
1823    log::info!("Provider reloaded successfully: {}", new_config.provider);
1824
1825    Ok(HttpResponse::Ok().json(serde_json::json!({
1826        "success": true,
1827        "provider": new_config.provider
1828    })))
1829}
1830
1831/// Configures settings-related routes
1832///
1833/// # Routes
1834/// ## Workflows
1835/// - `GET /bamboo/workflows` - List all workflows
1836/// - `GET /bamboo/workflows/{name}` - Get workflow content
1837/// - `POST /bamboo/workflows` - Create/update workflow
1838/// - `DELETE /bamboo/workflows/{name}` - Delete workflow
1839///
1840/// ## Setup
1841/// - `GET /bamboo/setup/status` - Get setup status
1842/// - `POST /bamboo/setup/complete` - Mark setup complete
1843/// - `POST /bamboo/setup/incomplete` - Reset setup status
1844///
1845/// ## Configuration
1846/// - `GET /bamboo/config` - Get application config
1847/// - `POST /bamboo/config` - Update application config
1848/// - `POST /bamboo/config/reset` - Reset configuration
1849/// - `POST /bamboo/proxy-auth` - Set proxy credentials
1850/// - `GET /bamboo/proxy-auth/status` - Get proxy auth status
1851///
1852/// ## Keyword Masking
1853/// - `GET /bamboo/keyword-masking` - Get keyword masking config
1854/// - `POST /bamboo/keyword-masking` - Update keyword masking
1855/// - `POST /bamboo/keyword-masking/validate` - Validate entries
1856///
1857/// ## Provider Settings
1858/// - `GET /bamboo/settings/provider` - Get provider config
1859/// - `POST /bamboo/settings/provider` - Update provider config
1860/// - `POST /bamboo/settings/provider/models` - Fetch available models
1861/// - `POST /bamboo/settings/reload` - Reload provider
1862///
1863/// ## Other
1864/// - `GET /bamboo/anthropic-model-mapping` - Get model mapping
1865/// - `POST /bamboo/anthropic-model-mapping` - Update model mapping
1866pub fn config(cfg: &mut web::ServiceConfig) {
1867    cfg.route("/bamboo/workflows", web::get().to(list_workflows))
1868        .route("/bamboo/workflows/{name}", web::get().to(get_workflow))
1869        .route("/bamboo/workflows", web::post().to(save_workflow))
1870        .route(
1871            "/bamboo/workflows/{name}",
1872            web::delete().to(delete_workflow),
1873        )
1874        // Setup status endpoints
1875        .route("/bamboo/setup/status", web::get().to(get_setup_status))
1876        .route(
1877            "/bamboo/setup/complete",
1878            web::post().to(mark_setup_complete),
1879        )
1880        .route(
1881            "/bamboo/setup/incomplete",
1882            web::post().to(mark_setup_incomplete),
1883        )
1884        // Config endpoints
1885        .route("/bamboo/config", web::get().to(get_bamboo_config))
1886        .route("/bamboo/config", web::post().to(set_bamboo_config))
1887        .route("/bamboo/config/reset", web::post().to(reset_bamboo_config))
1888        // Proxy auth endpoints (also registered with rate limiting in production via app_config_with_rate_limiting)
1889        .route("/bamboo/proxy-auth", web::post().to(set_proxy_auth))
1890        .route(
1891            "/bamboo/proxy-auth/status",
1892            web::get().to(get_proxy_auth_status),
1893        )
1894        // Keyword masking endpoints
1895        .route(
1896            "/bamboo/keyword-masking",
1897            web::get().to(get_keyword_masking_config),
1898        )
1899        .route(
1900            "/bamboo/keyword-masking",
1901            web::post().to(update_keyword_masking_config),
1902        )
1903        .route(
1904            "/bamboo/keyword-masking/validate",
1905            web::post().to(validate_keyword_entries),
1906        )
1907        // Provider configuration endpoints
1908        .route(
1909            "/bamboo/settings/provider",
1910            web::get().to(get_provider_config),
1911        )
1912        .route(
1913            "/bamboo/settings/provider",
1914            web::post().to(update_provider_config),
1915        )
1916        .route(
1917            "/bamboo/settings/provider/models",
1918            web::post().to(fetch_provider_models),
1919        )
1920        .route(
1921            "/bamboo/settings/reload",
1922            web::post().to(reload_provider_config),
1923        )
1924        // Other endpoints
1925        .route(
1926            "/bamboo/anthropic-model-mapping",
1927            web::get().to(get_anthropic_model_mapping),
1928        )
1929        .route(
1930            "/bamboo/anthropic-model-mapping",
1931            web::post().to(set_anthropic_model_mapping),
1932        );
1933}