diff --git a/src-tauri/src/ai/openai.rs b/src-tauri/src/ai/openai.rs index c57635a4..6cb1685c 100644 --- a/src-tauri/src/ai/openai.rs +++ b/src-tauri/src/ai/openai.rs @@ -28,9 +28,33 @@ impl Provider for OpenAiProvider { &self, messages: Vec, config: &ProviderConfig, + ) -> anyhow::Result { + // Check if using MSI GenAI format + let api_format = config.api_format.as_deref().unwrap_or("openai"); + + if api_format == "msi_genai" { + self.chat_msi_genai(messages, config).await + } else { + self.chat_openai(messages, config).await + } + } +} + +impl OpenAiProvider { + /// OpenAI-compatible API format (default) + async fn chat_openai( + &self, + messages: Vec, + config: &ProviderConfig, ) -> anyhow::Result { let client = reqwest::Client::new(); - let url = format!("{}/chat/completions", config.api_url.trim_end_matches('/')); + + // Use custom endpoint path if provided, otherwise default to /chat/completions + let endpoint_path = config + .custom_endpoint_path + .as_deref() + .unwrap_or("/chat/completions"); + let url = format!("{}{}", config.api_url.trim_end_matches('/'), endpoint_path); let body = serde_json::json!({ "model": config.model, @@ -38,9 +62,14 @@ impl Provider for OpenAiProvider { "max_tokens": 4096, }); + // Use custom auth header and prefix if provided + let auth_header = config.custom_auth_header.as_deref().unwrap_or("Authorization"); + let auth_prefix = config.custom_auth_prefix.as_deref().unwrap_or("Bearer "); + let auth_value = format!("{}{}", auth_prefix, config.api_key); + let resp = client .post(&url) - .header("Authorization", format!("Bearer {}", config.api_key)) + .header(auth_header, auth_value) .header("Content-Type", "application/json") .json(&body) .send() @@ -72,4 +101,89 @@ impl Provider for OpenAiProvider { usage, }) } + + /// MSI GenAI custom format + async fn chat_msi_genai( + &self, + messages: Vec, + config: &ProviderConfig, + ) -> anyhow::Result { + let client = reqwest::Client::new(); + + // Use custom endpoint path, default to empty (API URL already includes /api/v2/chat) + let endpoint_path = config.custom_endpoint_path.as_deref().unwrap_or(""); + let url = format!("{}{}", config.api_url.trim_end_matches('/'), endpoint_path); + + // Extract system message if present + let system_message = messages + .iter() + .find(|m| m.role == "system") + .map(|m| m.content.clone()); + + // Get last user message as prompt + let prompt = messages + .iter() + .rev() + .find(|m| m.role == "user") + .map(|m| m.content.clone()) + .ok_or_else(|| anyhow::anyhow!("No user message found"))?; + + // Build request body + let mut body = serde_json::json!({ + "model": config.model, + "prompt": prompt, + "userId": "user@motorolasolutions.com", // Default user ID + }); + + // Add optional system message + if let Some(system) = system_message { + body["system"] = serde_json::Value::String(system); + } + + // Add session ID if available (for conversation continuity) + if let Some(session_id) = &config.session_id { + body["sessionId"] = serde_json::Value::String(session_id.clone()); + } + + // Use custom auth header and prefix (no prefix for MSI GenAI) + let auth_header = config + .custom_auth_header + .as_deref() + .unwrap_or("x-msi-genai-api-key"); + let auth_prefix = config.custom_auth_prefix.as_deref().unwrap_or(""); + let auth_value = format!("{}{}", auth_prefix, config.api_key); + + let resp = client + .post(&url) + .header(auth_header, auth_value) + .header("Content-Type", "application/json") + .json(&body) + .send() + .await?; + + if !resp.status().is_success() { + let status = resp.status(); + let text = resp.text().await?; + anyhow::bail!("MSI GenAI API error {status}: {text}"); + } + + let json: serde_json::Value = resp.json().await?; + + // Extract response content from "msg" field + let content = json["msg"] + .as_str() + .ok_or_else(|| anyhow::anyhow!("No 'msg' field in response"))? + .to_string(); + + // Note: sessionId from response should be stored back to config.session_id + // This would require making config mutable or returning it as part of ChatResponse + // For now, the caller can extract it from the response if needed + // TODO: Consider adding session_id to ChatResponse struct + + Ok(ChatResponse { + content, + model: config.model.clone(), + usage: None, // MSI GenAI doesn't provide token usage in response + }) + } } diff --git a/src-tauri/src/state.rs b/src-tauri/src/state.rs index 52e98828..24dc838e 100644 --- a/src-tauri/src/state.rs +++ b/src-tauri/src/state.rs @@ -10,6 +10,25 @@ pub struct ProviderConfig { pub api_url: String, pub api_key: String, pub model: String, + /// Optional: Custom endpoint path (e.g., "" for no path, "/v1/chat" for custom path) + /// If None, defaults to "/chat/completions" for OpenAI compatibility + #[serde(skip_serializing_if = "Option::is_none")] + pub custom_endpoint_path: Option, + /// Optional: Custom auth header name (e.g., "x-msi-genai-api-key") + /// If None, defaults to "Authorization" + #[serde(skip_serializing_if = "Option::is_none")] + pub custom_auth_header: Option, + /// Optional: Custom auth value prefix (e.g., "" for no prefix, "Bearer " for OpenAI) + /// If None, defaults to "Bearer " + #[serde(skip_serializing_if = "Option::is_none")] + pub custom_auth_prefix: Option, + /// Optional: API format ("openai" or "msi_genai") + /// If None, defaults to "openai" + #[serde(skip_serializing_if = "Option::is_none")] + pub api_format: Option, + /// Optional: Session ID for stateful APIs like MSI GenAI + #[serde(skip_serializing_if = "Option::is_none")] + pub session_id: Option, } #[derive(Debug, Clone, Serialize, Deserialize)] diff --git a/src/lib/tauriCommands.ts b/src/lib/tauriCommands.ts index 9c04741b..cbe0e95f 100644 --- a/src/lib/tauriCommands.ts +++ b/src/lib/tauriCommands.ts @@ -10,6 +10,11 @@ export interface ProviderConfig { api_url: string; api_key: string; model: string; + custom_endpoint_path?: string; + custom_auth_header?: string; + custom_auth_prefix?: string; + api_format?: string; + session_id?: string; } export interface Message { diff --git a/src/pages/Settings/AIProviders.tsx b/src/pages/Settings/AIProviders.tsx index 1a02292c..48f5f1a1 100644 --- a/src/pages/Settings/AIProviders.tsx +++ b/src/pages/Settings/AIProviders.tsx @@ -27,6 +27,11 @@ const emptyProvider: ProviderConfig = { model: "", max_tokens: 4096, temperature: 0.7, + custom_endpoint_path: undefined, + custom_auth_header: undefined, + custom_auth_prefix: undefined, + api_format: undefined, + session_id: undefined, }; export default function AIProviders() { @@ -267,6 +272,89 @@ export default function AIProviders() { + {/* Custom provider format options */} + {form.provider_type === "custom" && ( + <> + +
+
+ + +

+ Select the API format. MSI GenAI uses a different request/response structure. +

+
+ +
+
+ + + setForm({ ...form, custom_endpoint_path: e.target.value }) + } + placeholder="/chat/completions" + /> +

+ Path appended to API URL. Leave empty if URL includes full path. +

+
+
+ + + setForm({ ...form, custom_auth_header: e.target.value }) + } + placeholder="Authorization" + /> +

+ Header name for authentication (e.g., "Authorization" or "x-msi-genai-api-key") +

+
+
+ +
+ + setForm({ ...form, custom_auth_prefix: e.target.value })} + placeholder="Bearer " + /> +

+ Prefix added before API key (e.g., "Bearer " for OpenAI, empty for MSI GenAI) +

+
+
+ + )} + {/* Test result */} {testResult && (