feat: add Custom REST provider support

- Extended ProviderConfig with optional custom fields for non-OpenAI APIs
- Added custom_endpoint_path, custom_auth_header, custom_auth_prefix fields
- Added api_format field to distinguish between OpenAI and Custom REST provider formats
- Added session_id field for stateful conversation APIs
- Implemented chat_custom_rest() method in OpenAI provider
- Custom REST provider uses different request format (prompt+sessionId) and response (msg field)
- Updated TypeScript types to match Rust schema
- Added UI controls in Settings/AIProviders for custom provider configuration
- API format selector auto-populates appropriate defaults (OpenAI vs Custom REST provider)
- Backward compatible: existing providers default to OpenAI format
This commit is contained in:
Shaun Arman 2026-04-03 15:45:42 -05:00
parent 4172616c8b
commit 190084888c
4 changed files with 228 additions and 2 deletions

View File

@ -28,9 +28,33 @@ impl Provider for OpenAiProvider {
&self,
messages: Vec<Message>,
config: &ProviderConfig,
) -> anyhow::Result<ChatResponse> {
// 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<Message>,
config: &ProviderConfig,
) -> anyhow::Result<ChatResponse> {
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<Message>,
config: &ProviderConfig,
) -> anyhow::Result<ChatResponse> {
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
})
}
}

View File

@ -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<String>,
/// 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<String>,
/// 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<String>,
/// Optional: API format ("openai" or "msi_genai")
/// If None, defaults to "openai"
#[serde(skip_serializing_if = "Option::is_none")]
pub api_format: Option<String>,
/// Optional: Session ID for stateful APIs like MSI GenAI
#[serde(skip_serializing_if = "Option::is_none")]
pub session_id: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]

View File

@ -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 {

View File

@ -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() {
</div>
</div>
{/* Custom provider format options */}
{form.provider_type === "custom" && (
<>
<Separator />
<div className="space-y-4">
<div className="space-y-2">
<Label>API Format</Label>
<Select
value={form.api_format ?? "openai"}
onValueChange={(v) => {
const format = v;
const defaults =
format === "msi_genai"
? {
custom_endpoint_path: "",
custom_auth_header: "x-msi-genai-api-key",
custom_auth_prefix: "",
}
: {
custom_endpoint_path: "/chat/completions",
custom_auth_header: "Authorization",
custom_auth_prefix: "Bearer ",
};
setForm({ ...form, api_format: format, ...defaults });
}}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="openai">OpenAI Compatible</SelectItem>
<SelectItem value="msi_genai">MSI GenAI</SelectItem>
</SelectContent>
</Select>
<p className="text-xs text-muted-foreground">
Select the API format. MSI GenAI uses a different request/response structure.
</p>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label>Endpoint Path</Label>
<Input
value={form.custom_endpoint_path ?? ""}
onChange={(e) =>
setForm({ ...form, custom_endpoint_path: e.target.value })
}
placeholder="/chat/completions"
/>
<p className="text-xs text-muted-foreground">
Path appended to API URL. Leave empty if URL includes full path.
</p>
</div>
<div className="space-y-2">
<Label>Auth Header Name</Label>
<Input
value={form.custom_auth_header ?? ""}
onChange={(e) =>
setForm({ ...form, custom_auth_header: e.target.value })
}
placeholder="Authorization"
/>
<p className="text-xs text-muted-foreground">
Header name for authentication (e.g., "Authorization" or "x-msi-genai-api-key")
</p>
</div>
</div>
<div className="space-y-2">
<Label>Auth Prefix</Label>
<Input
value={form.custom_auth_prefix ?? ""}
onChange={(e) => setForm({ ...form, custom_auth_prefix: e.target.value })}
placeholder="Bearer "
/>
<p className="text-xs text-muted-foreground">
Prefix added before API key (e.g., "Bearer " for OpenAI, empty for MSI GenAI)
</p>
</div>
</div>
</>
)}
{/* Test result */}
{testResult && (
<div