* perf: add reasoning_effort passthrough to avoid thinking-mode latency Reasoning models (Gemma 4, Qwen 3, etc.) default to thinking mode, adding 10-40x latency for simple transcript cleanup. Pass through the OpenAI-compatible reasoning_effort parameter so users can disable thinking when speed matters more than deep reasoning. - Add optional reasoning_effort field to ChatCompletionRequest - Thread through send_chat_completion / send_chat_completion_with_schema - New set_post_process_reasoning_effort Tauri command for persistence - Dropdown in post-processing settings, Custom provider only - i18n for en and ja * refactor: simplify reasoning_effort to on/off toggle Addresses review feedback on the reasoning_effort passthrough: post-processing rarely benefits from reasoning, so a 5-way dropdown (default/none/low/medium/high) is overkill. Collapse to a single boolean toggle "Disable reasoning" that defaults to ON for Custom providers (sends reasoning_effort: "none"). Cloud providers continue to receive no reasoning_effort parameter. - Replace post_process_reasoning_effort: Option<String> with post_process_disable_reasoning: bool (default true) in settings - Map bool to Option<String> at the request site (true -> "none", false -> omit) so the API contract is unchanged - Rename Tauri command to set_post_process_disable_reasoning(disable) so parameter naming matches the setting and avoids inversion bugs - Swap the Dropdown for a ToggleSwitch in the Custom provider section - Collapse five i18n strings to one label + description, and add the translation to all 18 additional locales so non-en/ja users don't fall back to English for this control * refactor: remove disable-reasoning toggle, always disable for custom provider Per PR #1221 discussion with @cjpais: since this is an alpha feature, simplify the UI by always sending reasoning_effort: "none" for custom provider post-processing. If this causes issues with specific provider setups, we can revisit. * disable for openrouter too --------- Co-authored-by: CJ Pais <cj@cjpais.com>
277 lines
8 KiB
Rust
277 lines
8 KiB
Rust
use crate::settings::PostProcessProvider;
|
|
use log::debug;
|
|
use reqwest::header::{HeaderMap, HeaderValue, AUTHORIZATION, CONTENT_TYPE, REFERER, USER_AGENT};
|
|
use serde::{Deserialize, Serialize};
|
|
use serde_json::Value;
|
|
|
|
#[derive(Debug, Serialize)]
|
|
struct ChatMessage {
|
|
role: String,
|
|
content: String,
|
|
}
|
|
|
|
#[derive(Debug, Serialize)]
|
|
struct JsonSchema {
|
|
name: String,
|
|
strict: bool,
|
|
schema: Value,
|
|
}
|
|
|
|
#[derive(Debug, Serialize)]
|
|
struct ResponseFormat {
|
|
#[serde(rename = "type")]
|
|
format_type: String,
|
|
json_schema: JsonSchema,
|
|
}
|
|
|
|
#[derive(Debug, Serialize, Clone, Default)]
|
|
pub struct ReasoningConfig {
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
pub effort: Option<String>,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
pub exclude: Option<bool>,
|
|
}
|
|
|
|
#[derive(Debug, Serialize)]
|
|
struct ChatCompletionRequest {
|
|
model: String,
|
|
messages: Vec<ChatMessage>,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
response_format: Option<ResponseFormat>,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
reasoning_effort: Option<String>,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
reasoning: Option<ReasoningConfig>,
|
|
}
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
struct ChatCompletionResponse {
|
|
choices: Vec<ChatChoice>,
|
|
}
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
struct ChatChoice {
|
|
message: ChatMessageResponse,
|
|
}
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
struct ChatMessageResponse {
|
|
content: Option<String>,
|
|
}
|
|
|
|
/// Build headers for API requests based on provider type
|
|
fn build_headers(provider: &PostProcessProvider, api_key: &str) -> Result<HeaderMap, String> {
|
|
let mut headers = HeaderMap::new();
|
|
|
|
// Common headers
|
|
headers.insert(CONTENT_TYPE, HeaderValue::from_static("application/json"));
|
|
headers.insert(
|
|
REFERER,
|
|
HeaderValue::from_static("https://github.com/cjpais/Handy"),
|
|
);
|
|
headers.insert(
|
|
USER_AGENT,
|
|
HeaderValue::from_static("Handy/1.0 (+https://github.com/cjpais/Handy)"),
|
|
);
|
|
headers.insert("X-Title", HeaderValue::from_static("Handy"));
|
|
|
|
// Provider-specific auth headers
|
|
if !api_key.is_empty() {
|
|
if provider.id == "anthropic" {
|
|
headers.insert(
|
|
"x-api-key",
|
|
HeaderValue::from_str(api_key)
|
|
.map_err(|e| format!("Invalid API key header value: {}", e))?,
|
|
);
|
|
headers.insert("anthropic-version", HeaderValue::from_static("2023-06-01"));
|
|
} else {
|
|
headers.insert(
|
|
AUTHORIZATION,
|
|
HeaderValue::from_str(&format!("Bearer {}", api_key))
|
|
.map_err(|e| format!("Invalid authorization header value: {}", e))?,
|
|
);
|
|
}
|
|
}
|
|
|
|
Ok(headers)
|
|
}
|
|
|
|
/// Create an HTTP client with provider-specific headers
|
|
fn create_client(provider: &PostProcessProvider, api_key: &str) -> Result<reqwest::Client, String> {
|
|
let headers = build_headers(provider, api_key)?;
|
|
reqwest::Client::builder()
|
|
.default_headers(headers)
|
|
.build()
|
|
.map_err(|e| format!("Failed to build HTTP client: {}", e))
|
|
}
|
|
|
|
/// Send a chat completion request to an OpenAI-compatible API
|
|
/// Returns Ok(Some(content)) on success, Ok(None) if response has no content,
|
|
/// or Err on actual errors (HTTP, parsing, etc.)
|
|
pub async fn send_chat_completion(
|
|
provider: &PostProcessProvider,
|
|
api_key: String,
|
|
model: &str,
|
|
prompt: String,
|
|
reasoning_effort: Option<String>,
|
|
reasoning: Option<ReasoningConfig>,
|
|
) -> Result<Option<String>, String> {
|
|
send_chat_completion_with_schema(
|
|
provider,
|
|
api_key,
|
|
model,
|
|
prompt,
|
|
None,
|
|
None,
|
|
reasoning_effort,
|
|
reasoning,
|
|
)
|
|
.await
|
|
}
|
|
|
|
/// Send a chat completion request with structured output support
|
|
/// When json_schema is provided, uses structured outputs mode
|
|
/// system_prompt is used as the system message when provided
|
|
/// reasoning_effort sets the OpenAI-style top-level field (e.g., "none", "low", "medium", "high")
|
|
/// reasoning sets the OpenRouter-style nested object (effort + exclude)
|
|
pub async fn send_chat_completion_with_schema(
|
|
provider: &PostProcessProvider,
|
|
api_key: String,
|
|
model: &str,
|
|
user_content: String,
|
|
system_prompt: Option<String>,
|
|
json_schema: Option<Value>,
|
|
reasoning_effort: Option<String>,
|
|
reasoning: Option<ReasoningConfig>,
|
|
) -> Result<Option<String>, String> {
|
|
let base_url = provider.base_url.trim_end_matches('/');
|
|
let url = format!("{}/chat/completions", base_url);
|
|
|
|
debug!("Sending chat completion request to: {}", url);
|
|
|
|
let client = create_client(provider, &api_key)?;
|
|
|
|
// Build messages vector
|
|
let mut messages = Vec::new();
|
|
|
|
// Add system prompt if provided
|
|
if let Some(system) = system_prompt {
|
|
messages.push(ChatMessage {
|
|
role: "system".to_string(),
|
|
content: system,
|
|
});
|
|
}
|
|
|
|
// Add user message
|
|
messages.push(ChatMessage {
|
|
role: "user".to_string(),
|
|
content: user_content,
|
|
});
|
|
|
|
// Build response_format if schema is provided
|
|
let response_format = json_schema.map(|schema| ResponseFormat {
|
|
format_type: "json_schema".to_string(),
|
|
json_schema: JsonSchema {
|
|
name: "transcription_output".to_string(),
|
|
strict: true,
|
|
schema,
|
|
},
|
|
});
|
|
|
|
let request_body = ChatCompletionRequest {
|
|
model: model.to_string(),
|
|
messages,
|
|
response_format,
|
|
reasoning_effort,
|
|
reasoning,
|
|
};
|
|
|
|
let response = client
|
|
.post(&url)
|
|
.json(&request_body)
|
|
.send()
|
|
.await
|
|
.map_err(|e| format!("HTTP request failed: {}", e))?;
|
|
|
|
let status = response.status();
|
|
if !status.is_success() {
|
|
let error_text = response
|
|
.text()
|
|
.await
|
|
.unwrap_or_else(|_| "Failed to read error response".to_string());
|
|
return Err(format!(
|
|
"API request failed with status {}: {}",
|
|
status, error_text
|
|
));
|
|
}
|
|
|
|
let completion: ChatCompletionResponse = response
|
|
.json()
|
|
.await
|
|
.map_err(|e| format!("Failed to parse API response: {}", e))?;
|
|
|
|
Ok(completion
|
|
.choices
|
|
.first()
|
|
.and_then(|choice| choice.message.content.clone()))
|
|
}
|
|
|
|
/// Fetch available models from an OpenAI-compatible API
|
|
/// Returns a list of model IDs
|
|
pub async fn fetch_models(
|
|
provider: &PostProcessProvider,
|
|
api_key: String,
|
|
) -> Result<Vec<String>, String> {
|
|
let base_url = provider.base_url.trim_end_matches('/');
|
|
let url = format!("{}/models", base_url);
|
|
|
|
debug!("Fetching models from: {}", url);
|
|
|
|
let client = create_client(provider, &api_key)?;
|
|
|
|
let response = client
|
|
.get(&url)
|
|
.send()
|
|
.await
|
|
.map_err(|e| format!("Failed to fetch models: {}", e))?;
|
|
|
|
let status = response.status();
|
|
if !status.is_success() {
|
|
let error_text = response
|
|
.text()
|
|
.await
|
|
.unwrap_or_else(|_| "Unknown error".to_string());
|
|
return Err(format!(
|
|
"Model list request failed ({}): {}",
|
|
status, error_text
|
|
));
|
|
}
|
|
|
|
let parsed: serde_json::Value = response
|
|
.json()
|
|
.await
|
|
.map_err(|e| format!("Failed to parse response: {}", e))?;
|
|
|
|
let mut models = Vec::new();
|
|
|
|
// Handle OpenAI format: { data: [ { id: "..." }, ... ] }
|
|
if let Some(data) = parsed.get("data").and_then(|d| d.as_array()) {
|
|
for entry in data {
|
|
if let Some(id) = entry.get("id").and_then(|i| i.as_str()) {
|
|
models.push(id.to_string());
|
|
} else if let Some(name) = entry.get("name").and_then(|n| n.as_str()) {
|
|
models.push(name.to_string());
|
|
}
|
|
}
|
|
}
|
|
// Handle array format: [ "model1", "model2", ... ]
|
|
else if let Some(array) = parsed.as_array() {
|
|
for entry in array {
|
|
if let Some(model) = entry.as_str() {
|
|
models.push(model.to_string());
|
|
}
|
|
}
|
|
}
|
|
|
|
Ok(models)
|
|
}
|