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:
parent
4172616c8b
commit
190084888c
@ -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
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@ -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)]
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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
|
||||
|
||||
Loading…
Reference in New Issue
Block a user