crabmate 0.5.0

Rust AI agent: OpenAI-compatible chat/completions, function calling, HTTP serve, ops CLI
Documentation
//! 根包 [`crate::cm_llm::StreamChatHost`] 实现(SSE、redact)。

use std::sync::atomic::AtomicBool;

use async_trait::async_trait;
use crate::cm_llm::{LlmCallError, StreamChatHost};
use crate::cm_types::{ChatRequest, Message, message_content_as_str};
use log::{debug, error, info};
use tokio::sync::mpsc::Sender;

use crate::redact::{
    self, CHAT_REQUEST_JSON_LOG_INFO_CHARS, CHAT_REQUEST_JSON_LOG_MAX_CHARS,
    HTTP_BODY_PREVIEW_LOG_CHARS,
};
use crate::sse::{SsePayload, ThinkingTraceBody, encode_message};

const THINKING_TRACE_CHUNK_MAX: usize = 4096;

fn clip_thinking_trace_text(s: &str) -> String {
    if s.len() <= THINKING_TRACE_CHUNK_MAX {
        return s.to_string();
    }
    let mut end = THINKING_TRACE_CHUNK_MAX;
    while end > 0 && !s.is_char_boundary(end) {
        end -= 1;
    }
    format!("{}", &s[..end])
}

#[cfg(test)]
mod tests {
    use super::{THINKING_TRACE_CHUNK_MAX, clip_thinking_trace_text};

    #[test]
    fn thinking_trace_clip_respects_utf8_boundary() {
        let s = format!("{}", "a".repeat(THINKING_TRACE_CHUNK_MAX - 1));
        let clipped = clip_thinking_trace_text(&s);

        assert!(clipped.ends_with(''));
        assert!(clipped.is_char_boundary(clipped.len()));
    }
}

/// 进程内默认 [`StreamChatHost`](Web / 运维 CLI 共用)。
#[derive(Debug, Copy, Clone, Default)]
pub struct CrabmateStreamChatHost;

#[async_trait]
impl StreamChatHost for CrabmateStreamChatHost {
    fn log_chat_request_json_preview_if_enabled(&self, req: &ChatRequest) {
        let as_debug = log::log_enabled!(log::Level::Debug);
        match serde_json::to_string(req) {
            Ok(body) => {
                if as_debug {
                    let preview = redact::preview_chars(&body, CHAT_REQUEST_JSON_LOG_MAX_CHARS);
                    debug!(
                        target: "crabmate",
                        "chat 请求体 JSON len={} messages_count={} body_preview={}",
                        body.len(),
                        req.messages.len(),
                        preview
                    );
                } else {
                    let preview = redact::preview_chars(&body, CHAT_REQUEST_JSON_LOG_INFO_CHARS);
                    info!(
                        target: "crabmate",
                        "chat 请求体 JSON len={} messages_count={} body_preview={}",
                        body.len(),
                        req.messages.len(),
                        preview
                    );
                }
            }
            Err(e) => {
                if as_debug {
                    debug!(
                        target: "crabmate",
                        "chat 请求体 JSON 序列化失败 err={}",
                        e
                    );
                } else {
                    info!(
                        target: "crabmate",
                        "chat 请求体 JSON 序列化失败 err={}",
                        e
                    );
                }
            }
        }
    }

    fn assistant_message_preview_for_log(&self, msg: &Message) -> String {
        redact::assistant_message_preview_for_log(msg)
    }

    fn append_stream_diagnostic_event(&self, stream_ended: &str, msg: &Message) {
        let answer_text = message_content_as_str(&msg.content).unwrap_or("");
        crate::turn_replay_dump::append_turn_replay_event_json_if_configured(
            "stream_diagnostic",
            "final_stream_status",
            Some(&serde_json::json!({
                "phase": "stream_diagnostic",
                "stream_ended": stream_ended,
                "answer_phase": !answer_text.is_empty(),
                "delta_chars": answer_text.chars().count(),
            })),
        );
    }

    fn llm_call_error_from_http_api(&self, status_code: u16, body: &str) -> LlmCallError {
        let preview = redact::single_line_preview(body, HTTP_BODY_PREVIEW_LOG_CHARS);
        error!(
            target: "crabmate",
            "chat completions API 返回非成功状态 status={} body_len={} body_preview={}",
            status_code,
            body.len(),
            preview
        );
        let err_text = match redact::chat_api_error_message_for_user(body) {
            Some(m) => format!("模型接口返回错误(HTTP {status_code}):{m}"),
            None => {
                format!("模型接口返回错误(HTTP {status_code}),请检查 API 密钥与配额,或稍后重试")
            }
        };
        LlmCallError::from_http_api(status_code, err_text)
    }

    fn boxed_non_stream_chat_parse_error(
        &self,
        body: &str,
        parse_err: &serde_json::Error,
    ) -> Box<dyn std::error::Error + Send + Sync> {
        let preview = redact::single_line_preview(body, HTTP_BODY_PREVIEW_LOG_CHARS);
        error!(
            target: "crabmate",
            "非流式 chat 响应 JSON 解析失败 err={} body_len={} body_preview={}",
            parse_err,
            body.len(),
            preview
        );
        Box::<dyn std::error::Error + Send + Sync>::from(
            "模型返回内容无法解析为预期格式,请稍后重试",
        )
    }

    async fn sse_out_send(
        &self,
        tx: &Sender<String>,
        line: String,
        context: &'static str,
        coop_cancel: Option<&AtomicBool>,
    ) -> bool {
        match coop_cancel {
            Some(c) => {
                crate::sse::send_string_logged_cooperative_cancel(tx, line, context, Some(c)).await
            }
            None => crate::sse::send_string_logged(tx, line, context).await,
        }
    }

    fn encode_assistant_answer_phase_sse(&self) -> String {
        encode_message(SsePayload::AssistantAnswerPhase {
            assistant_answer_phase: true,
        })
    }

    fn encode_parsing_tool_calls_sse(&self) -> String {
        encode_message(SsePayload::ParsingToolCalls {
            parsing_tool_calls: true,
        })
    }

    fn encode_turn_segment_start_sse(&self, tool_call_id: &str) -> String {
        encode_message(SsePayload::TurnSegmentStart {
            start: crate::sse::TurnSegmentStartBody {
                segment_id: format!("seg-before-{tool_call_id}"),
                kind: "commentary".to_string(),
                before_tool_call_id: Some(tool_call_id.to_string()),
            },
        })
    }

    fn encode_turn_segment_end_sse(&self, segment_id: &str) -> String {
        encode_message(SsePayload::TurnSegmentEnd {
            end: crate::sse::TurnSegmentEndBody {
                segment_id: segment_id.to_string(),
            },
        })
    }

    fn encode_thinking_trace_reasoning_delta_sse(&self, chunk: &str) -> String {
        encode_message(SsePayload::ThinkingTrace {
            trace: ThinkingTraceBody {
                op: "reasoning_delta".into(),
                node_id: Some("stream_reasoning".into()),
                parent_id: None,
                title: None,
                chunk: Some(clip_thinking_trace_text(chunk)),
                context_snapshot: None,
            },
        })
    }

    fn encode_thinking_trace_answer_phase_sse(&self) -> String {
        encode_message(SsePayload::ThinkingTrace {
            trace: ThinkingTraceBody {
                op: "answer_phase".into(),
                node_id: Some("stream_answer".into()),
                parent_id: Some("stream_reasoning".into()),
                title: Some("assistant_answer_phase".into()),
                chunk: None,
                context_snapshot: None,
            },
        })
    }

    fn encode_reasoning_content_sse(&self, chunk: &str) -> String {
        crate::sse::encode_reasoning_message_content_sse(chunk)
    }

    fn encode_answer_content_sse(&self, chunk: &str) -> String {
        crate::sse::encode_text_message_content_sse(chunk)
    }

    fn encode_text_message_start_sse(&self) -> String {
        crate::sse::encode_text_message_start_sse_str()
    }
}

/// 进程内默认 [`StreamChatHost`] 实例。
pub static CRABMATE_STREAM_CHAT_HOST: CrabmateStreamChatHost = CrabmateStreamChatHost;