fix(provider): strip <think> tags and merge system messages for MiniMax
MiniMax API rejects role: system in the messages array with error 2013 (invalid message role: system). In channel mode, the history builder prepends a system message and optionally appends a second one for delivery instructions, causing 400 errors on every channel turn. Additionally, MiniMax reasoning models embed chain-of-thought in the content field as <think>...</think> blocks rather than using the separate reasoning_content field, causing raw thinking output to leak into user-visible responses. Changes: - Add merge_system_into_user flag to OpenAiCompatibleProvider; when set, all system messages are concatenated and prepended to the first user message before sending to the API - Add new_merge_system_into_user() constructor used by MiniMax - Add strip_think_tags() helper that removes <think>...</think> blocks from response content before returning to the caller - Apply strip_think_tags in effective_content() and effective_content_optional() so all non-streaming paths are covered - Update MiniMax factory registration to use new_merge_system_into_user - Fix pre-existing rustfmt violation on apply_auth_header call All other providers continue to use the default path unchanged. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
d33eadea75
commit
db7b24b319
2 changed files with 121 additions and 22 deletions
|
|
@ -26,6 +26,10 @@ pub struct OpenAiCompatibleProvider {
|
||||||
/// GLM/Zhipu does not support the responses API.
|
/// GLM/Zhipu does not support the responses API.
|
||||||
supports_responses_fallback: bool,
|
supports_responses_fallback: bool,
|
||||||
user_agent: Option<String>,
|
user_agent: Option<String>,
|
||||||
|
/// When true, collect all `system` messages and prepend their content
|
||||||
|
/// to the first `user` message, then drop the system messages.
|
||||||
|
/// Required for providers that reject `role: system` (e.g. MiniMax).
|
||||||
|
merge_system_into_user: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// How the provider expects the API key to be sent.
|
/// How the provider expects the API key to be sent.
|
||||||
|
|
@ -46,7 +50,7 @@ impl OpenAiCompatibleProvider {
|
||||||
credential: Option<&str>,
|
credential: Option<&str>,
|
||||||
auth_style: AuthStyle,
|
auth_style: AuthStyle,
|
||||||
) -> Self {
|
) -> Self {
|
||||||
Self::new_with_options(name, base_url, credential, auth_style, true, None)
|
Self::new_with_options(name, base_url, credential, auth_style, true, None, false)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Same as `new` but skips the /v1/responses fallback on 404.
|
/// Same as `new` but skips the /v1/responses fallback on 404.
|
||||||
|
|
@ -57,7 +61,7 @@ impl OpenAiCompatibleProvider {
|
||||||
credential: Option<&str>,
|
credential: Option<&str>,
|
||||||
auth_style: AuthStyle,
|
auth_style: AuthStyle,
|
||||||
) -> Self {
|
) -> Self {
|
||||||
Self::new_with_options(name, base_url, credential, auth_style, false, None)
|
Self::new_with_options(name, base_url, credential, auth_style, false, None, false)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Create a provider with a custom User-Agent header.
|
/// Create a provider with a custom User-Agent header.
|
||||||
|
|
@ -78,9 +82,21 @@ impl OpenAiCompatibleProvider {
|
||||||
auth_style,
|
auth_style,
|
||||||
true,
|
true,
|
||||||
Some(user_agent),
|
Some(user_agent),
|
||||||
|
false,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// For providers that do not support `role: system` (e.g. MiniMax).
|
||||||
|
/// System prompt content is prepended to the first user message instead.
|
||||||
|
pub fn new_merge_system_into_user(
|
||||||
|
name: &str,
|
||||||
|
base_url: &str,
|
||||||
|
credential: Option<&str>,
|
||||||
|
auth_style: AuthStyle,
|
||||||
|
) -> Self {
|
||||||
|
Self::new_with_options(name, base_url, credential, auth_style, false, None, true)
|
||||||
|
}
|
||||||
|
|
||||||
fn new_with_options(
|
fn new_with_options(
|
||||||
name: &str,
|
name: &str,
|
||||||
base_url: &str,
|
base_url: &str,
|
||||||
|
|
@ -88,6 +104,7 @@ impl OpenAiCompatibleProvider {
|
||||||
auth_style: AuthStyle,
|
auth_style: AuthStyle,
|
||||||
supports_responses_fallback: bool,
|
supports_responses_fallback: bool,
|
||||||
user_agent: Option<&str>,
|
user_agent: Option<&str>,
|
||||||
|
merge_system_into_user: bool,
|
||||||
) -> Self {
|
) -> Self {
|
||||||
Self {
|
Self {
|
||||||
name: name.to_string(),
|
name: name.to_string(),
|
||||||
|
|
@ -96,9 +113,41 @@ impl OpenAiCompatibleProvider {
|
||||||
auth_header: auth_style,
|
auth_header: auth_style,
|
||||||
supports_responses_fallback,
|
supports_responses_fallback,
|
||||||
user_agent: user_agent.map(ToString::to_string),
|
user_agent: user_agent.map(ToString::to_string),
|
||||||
|
merge_system_into_user,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Collect all `system` role messages, concatenate their content,
|
||||||
|
/// and prepend to the first `user` message. Drop all system messages.
|
||||||
|
/// Used for providers (e.g. MiniMax) that reject `role: system`.
|
||||||
|
fn flatten_system_messages(messages: &[ChatMessage]) -> Vec<ChatMessage> {
|
||||||
|
let system_content: String = messages
|
||||||
|
.iter()
|
||||||
|
.filter(|m| m.role == "system")
|
||||||
|
.map(|m| m.content.as_str())
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join("\n\n");
|
||||||
|
|
||||||
|
if system_content.is_empty() {
|
||||||
|
return messages.to_vec();
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut result: Vec<ChatMessage> = messages
|
||||||
|
.iter()
|
||||||
|
.filter(|m| m.role != "system")
|
||||||
|
.cloned()
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
if let Some(first_user) = result.iter_mut().find(|m| m.role == "user") {
|
||||||
|
first_user.content = format!("{system_content}\n\n{}", first_user.content);
|
||||||
|
} else {
|
||||||
|
// No user message found: insert a synthetic user message with system content
|
||||||
|
result.insert(0, ChatMessage::user(&system_content));
|
||||||
|
}
|
||||||
|
|
||||||
|
result
|
||||||
|
}
|
||||||
|
|
||||||
fn http_client(&self) -> Client {
|
fn http_client(&self) -> Client {
|
||||||
if let Some(ua) = self.user_agent.as_deref() {
|
if let Some(ua) = self.user_agent.as_deref() {
|
||||||
let mut headers = HeaderMap::new();
|
let mut headers = HeaderMap::new();
|
||||||
|
|
@ -230,6 +279,30 @@ struct Choice {
|
||||||
message: ResponseMessage,
|
message: ResponseMessage,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Remove `<think>...</think>` blocks from model output.
|
||||||
|
/// Some reasoning models (e.g. MiniMax) embed their chain-of-thought inline
|
||||||
|
/// in the `content` field rather than a separate `reasoning_content` field.
|
||||||
|
/// The resulting `<think>` tags must be stripped before returning to the user.
|
||||||
|
fn strip_think_tags(s: &str) -> String {
|
||||||
|
let mut result = String::with_capacity(s.len());
|
||||||
|
let mut rest = s;
|
||||||
|
loop {
|
||||||
|
if let Some(start) = rest.find("<think>") {
|
||||||
|
result.push_str(&rest[..start]);
|
||||||
|
if let Some(end) = rest[start..].find("</think>") {
|
||||||
|
rest = &rest[start + end + "</think>".len()..];
|
||||||
|
} else {
|
||||||
|
// Unclosed tag: drop the rest to avoid leaking partial reasoning.
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
result.push_str(rest);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
result.trim().to_string()
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Deserialize, Serialize)]
|
#[derive(Debug, Deserialize, Serialize)]
|
||||||
struct ResponseMessage {
|
struct ResponseMessage {
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
|
|
@ -246,18 +319,22 @@ impl ResponseMessage {
|
||||||
/// Extract text content, falling back to `reasoning_content` when `content`
|
/// Extract text content, falling back to `reasoning_content` when `content`
|
||||||
/// is missing or empty. Reasoning/thinking models (Qwen3, GLM-4, etc.)
|
/// is missing or empty. Reasoning/thinking models (Qwen3, GLM-4, etc.)
|
||||||
/// often return their output solely in `reasoning_content`.
|
/// often return their output solely in `reasoning_content`.
|
||||||
|
/// Strips `<think>...</think>` blocks that some models (e.g. MiniMax) embed
|
||||||
|
/// inline in `content` instead of using a separate field.
|
||||||
fn effective_content(&self) -> String {
|
fn effective_content(&self) -> String {
|
||||||
match &self.content {
|
let raw = match &self.content {
|
||||||
Some(c) if !c.is_empty() => c.clone(),
|
Some(c) if !c.is_empty() => c.clone(),
|
||||||
_ => self.reasoning_content.clone().unwrap_or_default(),
|
_ => self.reasoning_content.clone().unwrap_or_default(),
|
||||||
}
|
};
|
||||||
|
strip_think_tags(&raw)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn effective_content_optional(&self) -> Option<String> {
|
fn effective_content_optional(&self) -> Option<String> {
|
||||||
match &self.content {
|
let raw = match &self.content {
|
||||||
Some(c) if !c.is_empty() => Some(c.clone()),
|
Some(c) if !c.is_empty() => Some(c.clone()),
|
||||||
_ => self.reasoning_content.clone().filter(|c| !c.is_empty()),
|
_ => self.reasoning_content.clone().filter(|c| !c.is_empty()),
|
||||||
}
|
};
|
||||||
|
raw.map(|s| strip_think_tags(&s)).filter(|s| !s.is_empty())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -786,17 +863,27 @@ impl Provider for OpenAiCompatibleProvider {
|
||||||
|
|
||||||
let mut messages = Vec::new();
|
let mut messages = Vec::new();
|
||||||
|
|
||||||
|
if self.merge_system_into_user {
|
||||||
|
let content = match system_prompt {
|
||||||
|
Some(sys) => format!("{sys}\n\n{message}"),
|
||||||
|
None => message.to_string(),
|
||||||
|
};
|
||||||
|
messages.push(Message {
|
||||||
|
role: "user".to_string(),
|
||||||
|
content,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
if let Some(sys) = system_prompt {
|
if let Some(sys) = system_prompt {
|
||||||
messages.push(Message {
|
messages.push(Message {
|
||||||
role: "system".to_string(),
|
role: "system".to_string(),
|
||||||
content: sys.to_string(),
|
content: sys.to_string(),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
messages.push(Message {
|
messages.push(Message {
|
||||||
role: "user".to_string(),
|
role: "user".to_string(),
|
||||||
content: message.to_string(),
|
content: message.to_string(),
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
|
||||||
let request = ApiChatRequest {
|
let request = ApiChatRequest {
|
||||||
model: model.to_string(),
|
model: model.to_string(),
|
||||||
|
|
@ -892,7 +979,12 @@ impl Provider for OpenAiCompatibleProvider {
|
||||||
)
|
)
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
let api_messages: Vec<Message> = messages
|
let effective_messages = if self.merge_system_into_user {
|
||||||
|
Self::flatten_system_messages(messages)
|
||||||
|
} else {
|
||||||
|
messages.to_vec()
|
||||||
|
};
|
||||||
|
let api_messages: Vec<Message> = effective_messages
|
||||||
.iter()
|
.iter()
|
||||||
.map(|m| Message {
|
.map(|m| Message {
|
||||||
role: m.role.clone(),
|
role: m.role.clone(),
|
||||||
|
|
@ -1104,9 +1196,14 @@ impl Provider for OpenAiCompatibleProvider {
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
let tools = Self::convert_tool_specs(request.tools);
|
let tools = Self::convert_tool_specs(request.tools);
|
||||||
|
let effective_messages = if self.merge_system_into_user {
|
||||||
|
Self::flatten_system_messages(request.messages)
|
||||||
|
} else {
|
||||||
|
request.messages.to_vec()
|
||||||
|
};
|
||||||
let native_request = NativeChatRequest {
|
let native_request = NativeChatRequest {
|
||||||
model: model.to_string(),
|
model: model.to_string(),
|
||||||
messages: Self::convert_messages_for_native(request.messages),
|
messages: Self::convert_messages_for_native(&effective_messages),
|
||||||
temperature,
|
temperature,
|
||||||
stream: Some(false),
|
stream: Some(false),
|
||||||
tool_choice: tools.as_ref().map(|_| "auto".to_string()),
|
tool_choice: tools.as_ref().map(|_| "auto".to_string()),
|
||||||
|
|
|
||||||
|
|
@ -661,12 +661,14 @@ pub fn create_provider_with_url(
|
||||||
AuthStyle::Bearer,
|
AuthStyle::Bearer,
|
||||||
)))
|
)))
|
||||||
}
|
}
|
||||||
name if minimax_base_url(name).is_some() => Ok(Box::new(OpenAiCompatibleProvider::new(
|
name if minimax_base_url(name).is_some() => Ok(Box::new(
|
||||||
|
OpenAiCompatibleProvider::new_merge_system_into_user(
|
||||||
"MiniMax",
|
"MiniMax",
|
||||||
minimax_base_url(name).expect("checked in guard"),
|
minimax_base_url(name).expect("checked in guard"),
|
||||||
key,
|
key,
|
||||||
AuthStyle::Bearer,
|
AuthStyle::Bearer,
|
||||||
))),
|
)
|
||||||
|
)),
|
||||||
"bedrock" | "aws-bedrock" => Ok(Box::new(OpenAiCompatibleProvider::new(
|
"bedrock" | "aws-bedrock" => Ok(Box::new(OpenAiCompatibleProvider::new(
|
||||||
"Amazon Bedrock",
|
"Amazon Bedrock",
|
||||||
"https://bedrock-runtime.us-east-1.amazonaws.com",
|
"https://bedrock-runtime.us-east-1.amazonaws.com",
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue