1 line
342 KiB
JSON
1 line
342 KiB
JSON
{"repo": "ardenzc/zen-canvas", "files": {"src-tauri/src/ai/registry.rs": {"9ea69d29143b994c8632747ab647f59637dfe324": {"t": 1785745843.540229, "n": 23417, "h": "1f108b13a19604d0", "c": "use serde::{Deserialize, Serialize};\n\nuse super::schema::{AIProviderKind, AIProviderPresetId};\n\n#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]\n#[serde(rename_all = \"snake_case\")]\npub enum AIAuthKind {\n None,\n BearerApiKey,\n ApiKeyHeader,\n QianfanAkSk,\n}\n\n#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]\n#[serde(rename_all = \"snake_case\")]\npub enum AITokenParameter {\n MaxTokens,\n MaxCompletionTokens,\n}\n\n#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]\n#[serde(rename_all = \"snake_case\")]\npub enum AIThinkingStrategy {\n None,\n DeepSeekThinkingObject,\n GenericThinkingObject,\n EnableThinkingBoolean,\n ReasoningEffort,\n MiniMaxReasoningSplit,\n PromptOnly,\n}\n\n#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]\n#[serde(rename_all = \"snake_case\")]\npub enum ResponseFieldPath {\n ChoicesMessageContent,\n ChoicesMessageOutputText,\n ChoicesMessageText,\n ChoicesMessageReasoningContent,\n ChoicesMessageReasoningDetails,\n RootOutputText,\n RootContent,\n}\n\n#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]\n#[serde(rename_all = \"camelCase\")]\npub struct AIProviderCapabilities {\n pub supports_model_discovery: bool,\n pub supports_response_format_json_object: bool,\n pub supports_json_schema: bool,\n pub supports_thinking: bool,\n pub supports_thinking_toggle: bool,\n pub supports_reasoning_effort: bool,\n pub supports_usage: bool,\n pub supports_streaming: bool,\n}\n\n#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]\n#[serde(rename_all = \"camelCase\")]\npub struct AIParameterProfile {\n pub temperature_min: f64,\n pub temperature_max: f64,\n pub default_temperature: f64,\n pub max_output_tokens: Option<u32>,\n pub token_parameter: AITokenParameter,\n pub thinking_strategy: AIThinkingStrategy,\n}\n\n#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]\n#[serde(rename_all = \"camelCase\")]\npub struct AIResponseProfile {\n pub content_paths: &'static [ResponseFieldPath],\n pub reasoning_paths: &'static [ResponseFieldPath],\n pub finish_reason_paths: &'static [ResponseFieldPath],\n pub usage_paths: &'static [ResponseFieldPath],\n pub strip_think_tags: bool,\n}\n\n#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]\n#[serde(rename_all = \"camelCase\")]\npub struct AIEndpointVariant {\n pub id: &'static str,\n pub label: &'static str,\n pub base_url: &'static str,\n}\n\n#[derive(Debug, Clone, Copy, PartialEq, Serialize)]\n#[serde(rename_all = \"camelCase\")]\npub struct AIProviderDescriptor {\n pub id: AIProviderPresetId,\n pub label: &'static str,\n pub provider_kind: AIProviderKind,\n pub default_base_url: &'static str,\n pub default_chat_path: &'static str,\n pub models_path: Option<&'static str>,\n pub default_model: &'static str,\n pub suggested_models: &'static [&'static str],\n pub auth_kind: AIAuthKind,\n pub capabilities: AIProviderCapabilities,\n pub parameter_profile: AIParameterProfile,\n pub response_profile: AIResponseProfile,\n pub endpoint_variants: &'static [AIEndpointVariant],\n pub api_key_env_hint: &'static str,\n pub docs_url: Option<&'static str>,\n}\n\nconst OPENAI_CONTENT_PATHS: &[ResponseFieldPath] = &[\n ResponseFieldPath::ChoicesMessageContent,\n ResponseFieldPath::ChoicesMessageOutputText,\n ResponseFieldPath::ChoicesMessageText,\n ResponseFieldPath::RootOutputText,\n ResponseFieldPath::RootContent,\n];\nconst OPENAI_REASONING_PATHS: &[ResponseFieldPath] = &[\n ResponseFieldPath::ChoicesMessageReasoningContent,\n ResponseFieldPath::ChoicesMessageReasoningDetails,\n];\nconst OPENAI_FINISH_PATHS: &[ResponseFieldPath] = &[];\nconst OPENAI_USAGE_PATHS: &[ResponseFieldPath] = &[];\nconst EMPTY_ENDPOINTS: &[AIEndpointVariant] = &[];\nconst DEEPSEEK_MODELS: &[&str] = &[\"deepseek-v4-flash\", \"deepseek-v4-pro\"];\nconst KIMI_MODELS: &[&str] = &[\"kimi-k2.6\", \"kimi-k2-thinking\", \"moonshot-v1-128k\"];\nconst QWEN_MODELS: &[&str] = &[\"qwen-plus\", \"qwen3.6-plus\", \"qwen3.5-plus\", \"qwen3-vl-plus\"];\nconst GLM_MODELS: &[&str] = &[\"glm-5.2\", \"glm-5\", \"glm-4.7\", \"glm-4.5\"];\nconst MINIMAX_MODELS: &[&str] = &[\"MiniMax-M2.5\", \"MiniMax-M2.1\", \"MiniMax-M2\"];\nconst DOUBAO_MODELS: &[&str] = &[\"ep-2024-...\", \"doubao-seed-1-6-250615\"];\nconst HUNYUAN_MODELS: &[&str] = &[\"hunyuan-turbos\", \"hunyuan-pro\"];\nconst STEPFUN_MODELS: &[&str] = &[\"step-3.5-flash\", \"step-3.5-turbo\"];\nconst OLLAMA_MODELS: &[&str] = &[\"qwen3:8b\", \"llama3.2:3b\"];\n\nconst DASHSCOPE_ENDPOINTS: &[AIEndpointVariant] = &[\n AIEndpointVariant {\n id: \"cn-beijing\",\n label: \"中国内地(北京)\",\n base_url: \"https://dashscope.aliyuncs.com/compatible-mode/v1\",\n },\n AIEndpointVariant {\n id: \"us-virginia\",\n label: \"美国(弗吉尼亚)\",\n base_url: \"https://dashscope-us.aliyuncs.com/compatible-mode/v1\",\n },\n AIEndpointVariant {\n id: \"singapore-workspace\",\n label: \"新加坡(Workspace Endpoint)\",\n base_url: \"https://{WorkspaceId}.ap-southeast-1.maas.aliyuncs.com/compatible-mode/v1\",\n },\n AIEndpointVariant {\n id: \"japan-workspace\",\n label: \"日本(Workspace Endpoint)\",\n base_url: \"https://{WorkspaceId}.ap-northeast-1.maas.aliyuncs.com/compatible-mode/v1\",\n },\n];\n\nconst OPENAI_RESPONSE_PROFILE: AIResponseProfile = AIResponseProfile {\n content_paths: OPENAI_CONTENT_PATHS,\n reasoning_paths: OPENAI_REASONING_PATHS,\n finish_reason_paths: OPENAI_FINISH_PATHS,\n usage_paths: OPENAI_USAGE_PATHS,\n strip_think_tags: false,\n};\nconst MINIMAX_RESPONSE_PROFILE: AIResponseProfile = AIResponseProfile {\n content_paths: OPENAI_CONTENT_PATHS,\n reasoning_paths: OPENAI_REASONING_PATHS,\n finish_reason_paths: OPENAI_FINISH_PATHS,\n usage_paths: OPENAI_USAGE_PATHS,\n strip_think_tags: true,\n};\n\nconst PROMPT_ONLY: AIProviderCapabilities = AIProviderCapabilities {\n supports_model_discovery: true,\n supports_response_format_json_object: false,\n supports_json_schema: false,\n supports_thinking: false,\n supports_thinking_toggle: false,\n supports_reasoning_effort: false,\n supports_usage: true,\n supports_streaming: true,\n};\n\nconst DEFAULT_OPENAI_PARAMETERS: AIParameterProfile = AIParameterProfile {\n temperature_min: 0.0,\n temperature_max: 2.0,\n default_temperature: 0.0,\n max_output_tokens: None,\n token_parameter: AITokenParameter::MaxTokens,\n thinking_strategy: AIThinkingStrategy::PromptOnly,\n};\nconst QWEN_CAPABILITIES: AIProviderCapabilities = AIProviderCapabilities {\n supports_model_discovery: true,\n supports_response_format_json_object: true,\n supports_json_schema: false,\n supports_thinking: true,\n supports_thinking_toggle: true,\n supports_reasoning_effort: false,\n supports_usage: true,\n supports_streaming: true,\n};\n\npub static PROVIDER_REGISTRY: &[AIProviderDescriptor] = &[\n AIProviderDescriptor {\n id: AIProviderPresetId::DeepSeek,\n label: \"DeepSeek\",\n provider_kind: AIProviderKind::OpenAICompatible,\n default_base_url: \"https://api.deepseek.com\",\n default_chat_path: \"/chat/completions\",\n models_path: Some(\"/models\"),\n default_model: \"deepseek-v4-flash\",\n suggested_models: DEEPSEEK_MODELS,\n auth_kind: AIAuthKind::BearerApiKey,\n capabilities: AIProviderCapabilities {\n supports_model_discovery: true,\n supports_response_format_json_object: true,\n supports_json_schema: false,\n supports_thinking: true,\n supports_thinking_toggle: true,\n supports_reasoning_effort: true,\n supports_usage: true,\n supports_streaming: true,\n },\n parameter_profile: AIParameterProfile {\n temperature_min: 0.0,\n temperature_max: 2.0,\n default_temperature: 0.0,\n max_output_tokens: Some(8192),\n token_parameter: AITokenParameter::MaxTokens,\n thinking_strategy: AIThinkingStrategy::DeepSeekThinkingObject,\n },\n response_profile: OPENAI_RESPONSE_PROFILE,\n endpoint_variants: EMPTY_ENDPOINTS,\n api_key_env_hint: \"DEEPSEEK_API_KEY\",\n docs_url: Some(\"https://api-docs.deepseek.com/\"),\n },\n AIProviderDescriptor {\n id: AIProviderPresetId::Kimi,\n label: \"Kimi / Moonshot\",\n provider_kind: AIProviderKind::OpenAICompatible,\n default_base_url: \"https://api.moonshot.ai/v1\",\n default_chat_path: \"/chat/completions\",\n models_path: Some(\"/models\"),\n default_model: \"kimi-k2.6\",\n suggested_models: KIMI_MODELS,\n auth_kind: AIAuthKind::BearerApiKey,\n capabilities: AIProviderCapabilities {\n supports_model_discovery: true,\n supports_response_format_json_object: true,\n supports_json_schema: false,\n supports_thinking: true,\n supports_thinking_toggle: true,\n supports_reasoning_effort: false,\n supports_usage: true,\n supports_streaming: true,\n },\n parameter_profile: AIParameterProfile {\n temperature_min: 0.0,\n temperature_max: 1.0,\n default_temperature: 0.0,\n max_output_tokens: Some(32768),\n token_parameter: AITokenParameter::MaxTokens,\n thinking_strategy: AIThinkingStrategy::GenericThinkingObject,\n },\n response_profile: OPENAI_RESPONSE_PROFILE,\n endpoint_variants: EMPTY_ENDPOINTS,\n api_key_env_hint: \"MOONSHOT_API_KEY\",\n docs_url: Some(\"https://platform.kimi.ai/docs\"),\n },\n AIProviderDescriptor {\n id: AIProviderPresetId::QwenDashScope,\n label: \"Qwen / 阿里云百炼\",\n provider_kind: AIProviderKind::OpenAICompatible,\n default_base_url: \"https://dashscope.aliyuncs.com/compatible-mode/v1\",\n default_chat_path: \"/chat/completions\",\n models_path: Some(\"/models\"),\n default_model: \"qwen-plus\",\n suggested_models: QWEN_MODELS,\n auth_kind: AIAuthKind::BearerApiKey,\n capabilities: QWEN_CAPABILITIES,\n parameter_profile: AIParameterProfile {\n temperature_min: 0.0,\n temperature_max: 2.0,\n default_temperature: 0.0,\n max_output_tokens: Some(32768),\n token_parameter: AITokenParameter::MaxTokens,\n thinking_strategy: AIThinkingStrategy::EnableThinkingBoolean,\n },\n response_profile: OPENAI_RESPONSE_PROFILE,\n endpoint_variants: DASHSCOPE_ENDPOINTS,\n api_key_env_hint: \"DASHSCOPE_API_KEY\",\n docs_url: Some(\"https://help.aliyun.com/model-studio/\"),\n },\n AIProviderDescriptor {\n id: AIProviderPresetId::ZhipuGlm,\n label: \"智谱 GLM\",\n provider_kind: AIProviderKind::OpenAICompatible,\n default_base_url: \"https://open.bigmodel.cn/api/paas/v4\",\n default_chat_path: \"/chat/completions\",\n models_path: None,\n default_model: \"glm-5.2\",\n suggested_models: GLM_MODELS,\n auth_kind: AIAuthKind::BearerApiKey,\n capabilities: AIProviderCapabilities {\n supports_model_discovery: false,\n supports_response_format_json_object: true,\n supports_json_schema: false,\n supports_thinking: true,\n supports_thinking_toggle: true,\n supports_reasoning_effort: true,\n supports_usage: true,\n supports_streaming: true,\n },\n parameter_profile: AIParameterProfile {\n temperature_min: 0.0,\n temperature_max: 2.0,\n default_temperature: 0.0,\n max_output_tokens: Some(32768),\n token_parameter: AITokenParameter::MaxTokens,\n thinking_strategy: AIThinkingStrategy::GenericThinkingObject,\n },\n response_profile: OPENAI_RESPONSE_PROFILE,\n endpoint_variants: EMPTY_ENDPOINTS,\n api_key_env_hint: \"ZHIPUAI_API_KEY\",\n docs_url: Some(\"https://docs.bigmodel.cn/\"),\n },\n AIProviderDescriptor {\n id: AIProviderPresetId::DoubaoArk,\n label: \"豆包 / 火山方舟\",\n provider_kind: AIProviderKind::OpenAICompatible,\n default_base_url: \"https://ark.cn-beijing.volces.com/api/v3\",\n default_chat_path: \"/chat/completions\",\n models_path: Some(\"/models\"),\n default_model: \"\",\n suggested_models: DOUBAO_MODELS,\n auth_kind: AIAuthKind::BearerApiKey,\n capabilities: PROMPT_ONLY,\n parameter_profile: AIParameterProfile {\n temperature_min: 0.0,\n temperature_max: 2.0,\n default_temperature: 0.0,\n max_output_tokens: Some(32768),\n token_parameter: AITokenParameter::MaxTokens,\n thinking_strategy: AIThinkingStrategy::PromptOnly,\n },\n response_profile: OPENAI_RESPONSE_PROFILE,\n endpoint_variants: EMPTY_ENDPOINTS,\n api_key_env_hint: \"ARK_API_KEY\",\n docs_url: Some(\"https://www.volcengine.com/docs/82379\"),\n },\n AIProviderDescriptor {\n id: AIProviderPresetId::Minimax,\n label: \"MiniMax\",\n provider_kind: AIProviderKind::OpenAICompatible,\n default_base_url: \"https://api.minimaxi.com/v1\",\n default_chat_path: \"/chat/completions\",\n models_path: Some(\"/models\"),\n default_model: \"MiniMax-M2.5\",\n suggested_models: MINIMAX_MODELS,\n auth_kind: AIAuthKind::BearerApiKey,\n capabilities: AIProviderCapabilities {\n supports_model_discovery: true,\n supports_response_format_json_object: true,\n supports_json_schema: false,\n supports_thinking: true,\n supports_thinking_toggle: true,\n supports_reasoning_effort: false,\n supports_usage: true,\n supports_streaming: true,\n },\n parameter_profile: AIParameterProfile {\n temperature_min: 0.0,\n temperature_max: 1.0,\n default_temperature: 0.0,\n max_output_tokens: Some(32768),\n token_parameter: AITokenParameter::MaxCompletionTokens,\n thinking_strategy: AIThinkingStrategy::MiniMaxReasoningSplit,\n },\n response_profile: MINIMAX_RESPONSE_PROFILE,\n endpoint_variants: EMPTY_ENDPOINTS,\n api_key_env_hint: \"MINIMAX_API_KEY\",\n docs_url: Some(\"https://platform.minimaxi.com/document\"),\n },\n AIProviderDescriptor {\n id: AIProviderPresetId::Hunyuan,\n label: \"腾讯混元\",\n provider_kind: AIProviderKind::OpenAICompatible,\n default_base_url: \"https://api.hunyuan.cloud.tencent.com/v1\",\n default_chat_path: \"/chat/completions\",\n models_path: Some(\"/models\"),\n default_model: \"hunyuan-turbos\",\n suggested_models: HUNYUAN_MODELS,\n auth_kind: AIAuthKind::BearerApiKey,\n capabilities: PROMPT_ONLY,\n parameter_profile: DEFAULT_OPENAI_PARAMETERS,\n response_profile: OPENAI_RESPONSE_PROFILE,\n endpoint_variants: EMPTY_ENDPOINTS,\n api_key_env_hint: \"HUNYUAN_API_KEY\",\n docs_url: Some(\"https://cloud.tencent.com/document/product/1729\"),\n },\n AIProviderDescriptor {\n id: AIProviderPresetId::Siliconflow,\n label: \"SiliconFlow / 硅基流动\",\n provider_kind: AIProviderKind::OpenAICompatible,\n default_base_url: \"https://api.siliconflow.cn/v1\",\n default_chat_path: \"/chat/completions\",\n models_path: Some(\"/models\"),\n default_model: \"\",\n suggested_models: &[],\n auth_kind: AIAuthKind::BearerApiKey,\n capabilities: PROMPT_ONLY,\n parameter_profile: DEFAULT_OPENAI_PARAMETERS,\n response_profile: OPENAI_RESPONSE_PROFILE,\n endpoint_variants: EMPTY_ENDPOINTS,\n api_key_env_hint: \"SILICONFLOW_API_KEY\",\n docs_url: Some(\"https://docs.siliconflow.cn/\"),\n },\n AIProviderDescriptor {\n id: AIProviderPresetId::BaiduQianfan,\n label: \"百度千帆 / 文心\",\n provider_kind: AIProviderKind::OpenAICompatible,\n default_base_url: \"https://qianfan.baidubce.com/v2\",\n default_chat_path: \"/chat/completions\",\n models_path: None,\n default_model: \"ernie-4.5-turbo-32k\",\n suggested_models: &[\"ernie-4.5-turbo-32k\", \"ernie-speed-128k\"],\n auth_kind: AIAuthKind::BearerApiKey,\n capabilities: PROMPT_ONLY,\n parameter_profile: DEFAULT_OPENAI_PARAMETERS,\n response_profile: OPENAI_RESPONSE_PROFILE,\n endpoint_variants: EMPTY_ENDPOINTS,\n api_key_env_hint: \"QIANFAN_API_KEY\",\n docs_url: Some(\"https://cloud.baidu.com/doc/WENXINWORKSHOP/\"),\n },\n AIProviderDescriptor {\n id: AIProviderPresetId::Baichuan,\n label: \"百川智能(兼容入口)\",\n provider_kind: AIProviderKind::OpenAICompatible,\n default_base_url: \"\",\n default_chat_path: \"/chat/completions\",\n models_path: None,\n default_model: \"\",\n suggested_models: &[\"Baichuan4-Turbo\"],\n auth_kind: AIAuthKind::BearerApiKey,\n capabilities: PROMPT_ONLY,\n parameter_profile: DEFAULT_OPENAI_PARAMETERS,\n response_profile: OPENAI_RESPONSE_PROFILE,\n endpoint_variants: EMPTY_ENDPOINTS,\n api_key_env_hint: \"BAICHUAN_API_KEY\",\n docs_url: Some(\"https://platform.baichuan-ai.com/docs/api\"),\n },\n AIProviderDescriptor {\n id: AIProviderPresetId::StepFun,\n label: \"阶跃星辰 / StepFun\",\n provider_kind: AIProviderKind::OpenAICompatible,\n default_base_url: \"https://api.stepfun.com/v1\",\n default_chat_path: \"/chat/completions\",\n models_path: Some(\"/models\"),\n default_model: \"step-3.5-flash\",\n suggested_models: STEPFUN_MODELS,\n auth_kind: AIAuthKind::BearerApiKey,\n capabilities: PROMPT_ONLY,\n parameter_profile: DEFAULT_OPENAI_PARAMETERS,\n response_profile: OPENAI_RESPONSE_PROFILE,\n endpoint_variants: EMPTY_ENDPOINTS,\n api_key_env_hint: \"STEPFUN_API_KEY\",\n docs_url: Some(\"https://platform.stepfun.com/docs\"),\n },\n AIProviderDescriptor {\n id: AIProviderPresetId::Yi,\n label: \"零一万物 / Yi(兼容平台)\",\n provider_kind: AIProviderKind::OpenAICompatible,\n default_base_url: \"\",\n default_chat_path: \"/chat/completions\",\n models_path: None,\n default_model: \"\",\n suggested_models: &[\"yi-large\"],\n auth_kind: AIAuthKind::BearerApiKey,\n capabilities: PROMPT_ONLY,\n parameter_profile: DEFAULT_OPENAI_PARAMETERS,\n response_profile: OPENAI_RESPONSE_PROFILE,\n endpoint_variants: EMPTY_ENDPOINTS,\n api_key_env_hint: \"YI_API_KEY\",\n docs_url: Some(\"https://platform.lingyiwanwu.com/\"),\n },\n AIProviderDescriptor {\n id: AIProviderPresetId::CustomOpenAICompatible,\n label: \"自定义 OpenAI-compatible\",\n provider_kind: AIProviderKind::OpenAICompatible,\n default_base_url: \"\",\n default_chat_path: \"/chat/completions\",\n models_path: Some(\"/models\"),\n default_model: \"\",\n suggested_models: &[],\n auth_kind: AIAuthKind::BearerApiKey,\n capabilities: PROMPT_ONLY,\n parameter_profile: DEFAULT_OPENAI_PARAMETERS,\n response_profile: OPENAI_RESPONSE_PROFILE,\n endpoint_variants: EMPTY_ENDPOINTS,\n api_key_env_hint: \"OPENAI_COMPATIBLE_API_KEY\",\n docs_url: None,\n },\n AIProviderDescriptor {\n id: AIProviderPresetId::Ollama,\n label: \"Ollama 本地模型\",\n provider_kind: AIProviderKind::Ollama,\n default_base_url: \"http://localhost:11434\",\n default_chat_path: \"/api/chat\",\n models_path: Some(\"/api/tags\"),\n default_model: \"qwen3:8b\",\n suggested_models: OLLAMA_MODELS,\n auth_kind: AIAuthKind::None,\n capabilities: AIProviderCapabilities {\n supports_model_discovery: true,\n supports_response_format_json_object: false,\n supports_json_schema: false,\n supports_thinking: true,\n supports_thinking_toggle: true,\n supports_reasoning_effort: false,\n supports_usage: true,\n supports_streaming: true,\n },\n parameter_profile: AIParameterProfile {\n temperature_min: 0.0,\n temperature_max: 2.0,\n default_temperature: 0.0,\n max_output_tokens: Some(32768),\n token_parameter: AITokenParameter::MaxTokens,\n thinking_strategy: AIThinkingStrategy::PromptOnly,\n },\n response_profile: OPENAI_RESPONSE_PROFILE,\n endpoint_variants: EMPTY_ENDPOINTS,\n api_key_env_hint: \"\",\n docs_url: Some(\"https://github.com/ollama/ollama/blob/main/docs/api.md\"),\n },\n];\n\npub fn provider_registry() -> &'static [AIProviderDescriptor] {\n PROVIDER_REGISTRY\n}\n\npub fn provider_descriptor(id: AIProviderPresetId) -> Option<&'static AIProviderDescriptor> {\n PROVIDER_REGISTRY\n .iter()\n .find(|descriptor| descriptor.id == id)\n}\n\n#[cfg(test)]\nmod tests {\n use super::*;\n\n #[test]\n fn registry_has_one_descriptor_for_each_supported_preset() {\n let expected = [\n AIProviderPresetId::DeepSeek,\n AIProviderPresetId::Kimi,\n AIProviderPresetId::QwenDashScope,\n AIProviderPresetId::ZhipuGlm,\n AIProviderPresetId::DoubaoArk,\n AIProviderPresetId::Minimax,\n AIProviderPresetId::Hunyuan,\n AIProviderPresetId::Siliconflow,\n AIProviderPresetId::BaiduQianfan,\n AIProviderPresetId::Baichuan,\n AIProviderPresetId::StepFun,\n AIProviderPresetId::Yi,\n AIProviderPresetId::CustomOpenAICompatible,\n AIProviderPresetId::Ollama,\n ];\n assert_eq!(provider_registry().len(), expected.len());\n for id in expected {\n assert_eq!(\n provider_registry()\n .iter()\n .filter(|item| item.id == id)\n .count(),\n 1\n );\n }\n }\n\n #[test]\n fn registry_snapshots_provider_specific_protocol_parameters() {\n let deepseek = provider_descriptor(AIProviderPresetId::DeepSeek).expect(\"DeepSeek\");\n assert_eq!(deepseek.default_base_url, \"https://api.deepseek.com\");\n assert!(deepseek.capabilities.supports_response_format_json_object);\n assert_eq!(\n deepseek.parameter_profile.thinking_strategy,\n AIThinkingStrategy::DeepSeekThinkingObject\n );\n\n let qwen = provider_descriptor(AIProviderPresetId::QwenDashScope).expect(\"Qwen\");\n assert!(qwen.capabilities.supports_response_format_json_object);\n assert_eq!(\n qwen.parameter_profile.thinking_strategy,\n AIThinkingStrategy::EnableThinkingBoolean\n );\n\n let minimax = provider_descriptor(AIProviderPresetId::Minimax).expect(\"MiniMax\");\n assert_eq!(\n minimax.parameter_profile.token_parameter,\n AITokenParameter::MaxCompletionTokens\n );\n assert_eq!(\n minimax.parameter_profile.thinking_strategy,\n AIThinkingStrategy::MiniMaxReasoningSplit\n );\n\n let qianfan = provider_descriptor(AIProviderPresetId::BaiduQianfan).expect(\"Qianfan\");\n assert_eq!(qianfan.auth_kind, AIAuthKind::BearerApiKey);\n assert_eq!(qianfan.default_base_url, \"https://qianfan.baidubce.com/v2\");\n }\n}\n"}, "f64cf0097f514b7395356b1f3df7c6af1020a12d": {"t": 1785757164.0500057, "n": 23417, "h": "1f108b13a19604d0", "c": "use serde::{Deserialize, Serialize};\n\nuse super::schema::{AIProviderKind, AIProviderPresetId};\n\n#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]\n#[serde(rename_all = \"snake_case\")]\npub enum AIAuthKind {\n None,\n BearerApiKey,\n ApiKeyHeader,\n QianfanAkSk,\n}\n\n#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]\n#[serde(rename_all = \"snake_case\")]\npub enum AITokenParameter {\n MaxTokens,\n MaxCompletionTokens,\n}\n\n#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]\n#[serde(rename_all = \"snake_case\")]\npub enum AIThinkingStrategy {\n None,\n DeepSeekThinkingObject,\n GenericThinkingObject,\n EnableThinkingBoolean,\n ReasoningEffort,\n MiniMaxReasoningSplit,\n PromptOnly,\n}\n\n#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]\n#[serde(rename_all = \"snake_case\")]\npub enum ResponseFieldPath {\n ChoicesMessageContent,\n ChoicesMessageOutputText,\n ChoicesMessageText,\n ChoicesMessageReasoningContent,\n ChoicesMessageReasoningDetails,\n RootOutputText,\n RootContent,\n}\n\n#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]\n#[serde(rename_all = \"camelCase\")]\npub struct AIProviderCapabilities {\n pub supports_model_discovery: bool,\n pub supports_response_format_json_object: bool,\n pub supports_json_schema: bool,\n pub supports_thinking: bool,\n pub supports_thinking_toggle: bool,\n pub supports_reasoning_effort: bool,\n pub supports_usage: bool,\n pub supports_streaming: bool,\n}\n\n#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]\n#[serde(rename_all = \"camelCase\")]\npub struct AIParameterProfile {\n pub temperature_min: f64,\n pub temperature_max: f64,\n pub default_temperature: f64,\n pub max_output_tokens: Option<u32>,\n pub token_parameter: AITokenParameter,\n pub thinking_strategy: AIThinkingStrategy,\n}\n\n#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]\n#[serde(rename_all = \"camelCase\")]\npub struct AIResponseProfile {\n pub content_paths: &'static [ResponseFieldPath],\n pub reasoning_paths: &'static [ResponseFieldPath],\n pub finish_reason_paths: &'static [ResponseFieldPath],\n pub usage_paths: &'static [ResponseFieldPath],\n pub strip_think_tags: bool,\n}\n\n#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]\n#[serde(rename_all = \"camelCase\")]\npub struct AIEndpointVariant {\n pub id: &'static str,\n pub label: &'static str,\n pub base_url: &'static str,\n}\n\n#[derive(Debug, Clone, Copy, PartialEq, Serialize)]\n#[serde(rename_all = \"camelCase\")]\npub struct AIProviderDescriptor {\n pub id: AIProviderPresetId,\n pub label: &'static str,\n pub provider_kind: AIProviderKind,\n pub default_base_url: &'static str,\n pub default_chat_path: &'static str,\n pub models_path: Option<&'static str>,\n pub default_model: &'static str,\n pub suggested_models: &'static [&'static str],\n pub auth_kind: AIAuthKind,\n pub capabilities: AIProviderCapabilities,\n pub parameter_profile: AIParameterProfile,\n pub response_profile: AIResponseProfile,\n pub endpoint_variants: &'static [AIEndpointVariant],\n pub api_key_env_hint: &'static str,\n pub docs_url: Option<&'static str>,\n}\n\nconst OPENAI_CONTENT_PATHS: &[ResponseFieldPath] = &[\n ResponseFieldPath::ChoicesMessageContent,\n ResponseFieldPath::ChoicesMessageOutputText,\n ResponseFieldPath::ChoicesMessageText,\n ResponseFieldPath::RootOutputText,\n ResponseFieldPath::RootContent,\n];\nconst OPENAI_REASONING_PATHS: &[ResponseFieldPath] = &[\n ResponseFieldPath::ChoicesMessageReasoningContent,\n ResponseFieldPath::ChoicesMessageReasoningDetails,\n];\nconst OPENAI_FINISH_PATHS: &[ResponseFieldPath] = &[];\nconst OPENAI_USAGE_PATHS: &[ResponseFieldPath] = &[];\nconst EMPTY_ENDPOINTS: &[AIEndpointVariant] = &[];\nconst DEEPSEEK_MODELS: &[&str] = &[\"deepseek-v4-flash\", \"deepseek-v4-pro\"];\nconst KIMI_MODELS: &[&str] = &[\"kimi-k2.6\", \"kimi-k2-thinking\", \"moonshot-v1-128k\"];\nconst QWEN_MODELS: &[&str] = &[\"qwen-plus\", \"qwen3.6-plus\", \"qwen3.5-plus\", \"qwen3-vl-plus\"];\nconst GLM_MODELS: &[&str] = &[\"glm-5.2\", \"glm-5\", \"glm-4.7\", \"glm-4.5\"];\nconst MINIMAX_MODELS: &[&str] = &[\"MiniMax-M2.5\", \"MiniMax-M2.1\", \"MiniMax-M2\"];\nconst DOUBAO_MODELS: &[&str] = &[\"ep-2024-...\", \"doubao-seed-1-6-250615\"];\nconst HUNYUAN_MODELS: &[&str] = &[\"hunyuan-turbos\", \"hunyuan-pro\"];\nconst STEPFUN_MODELS: &[&str] = &[\"step-3.5-flash\", \"step-3.5-turbo\"];\nconst OLLAMA_MODELS: &[&str] = &[\"qwen3:8b\", \"llama3.2:3b\"];\n\nconst DASHSCOPE_ENDPOINTS: &[AIEndpointVariant] = &[\n AIEndpointVariant {\n id: \"cn-beijing\",\n label: \"中国内地(北京)\",\n base_url: \"https://dashscope.aliyuncs.com/compatible-mode/v1\",\n },\n AIEndpointVariant {\n id: \"us-virginia\",\n label: \"美国(弗吉尼亚)\",\n base_url: \"https://dashscope-us.aliyuncs.com/compatible-mode/v1\",\n },\n AIEndpointVariant {\n id: \"singapore-workspace\",\n label: \"新加坡(Workspace Endpoint)\",\n base_url: \"https://{WorkspaceId}.ap-southeast-1.maas.aliyuncs.com/compatible-mode/v1\",\n },\n AIEndpointVariant {\n id: \"japan-workspace\",\n label: \"日本(Workspace Endpoint)\",\n base_url: \"https://{WorkspaceId}.ap-northeast-1.maas.aliyuncs.com/compatible-mode/v1\",\n },\n];\n\nconst OPENAI_RESPONSE_PROFILE: AIResponseProfile = AIResponseProfile {\n content_paths: OPENAI_CONTENT_PATHS,\n reasoning_paths: OPENAI_REASONING_PATHS,\n finish_reason_paths: OPENAI_FINISH_PATHS,\n usage_paths: OPENAI_USAGE_PATHS,\n strip_think_tags: false,\n};\nconst MINIMAX_RESPONSE_PROFILE: AIResponseProfile = AIResponseProfile {\n content_paths: OPENAI_CONTENT_PATHS,\n reasoning_paths: OPENAI_REASONING_PATHS,\n finish_reason_paths: OPENAI_FINISH_PATHS,\n usage_paths: OPENAI_USAGE_PATHS,\n strip_think_tags: true,\n};\n\nconst PROMPT_ONLY: AIProviderCapabilities = AIProviderCapabilities {\n supports_model_discovery: true,\n supports_response_format_json_object: false,\n supports_json_schema: false,\n supports_thinking: false,\n supports_thinking_toggle: false,\n supports_reasoning_effort: false,\n supports_usage: true,\n supports_streaming: true,\n};\n\nconst DEFAULT_OPENAI_PARAMETERS: AIParameterProfile = AIParameterProfile {\n temperature_min: 0.0,\n temperature_max: 2.0,\n default_temperature: 0.0,\n max_output_tokens: None,\n token_parameter: AITokenParameter::MaxTokens,\n thinking_strategy: AIThinkingStrategy::PromptOnly,\n};\nconst QWEN_CAPABILITIES: AIProviderCapabilities = AIProviderCapabilities {\n supports_model_discovery: true,\n supports_response_format_json_object: true,\n supports_json_schema: false,\n supports_thinking: true,\n supports_thinking_toggle: true,\n supports_reasoning_effort: false,\n supports_usage: true,\n supports_streaming: true,\n};\n\npub static PROVIDER_REGISTRY: &[AIProviderDescriptor] = &[\n AIProviderDescriptor {\n id: AIProviderPresetId::DeepSeek,\n label: \"DeepSeek\",\n provider_kind: AIProviderKind::OpenAICompatible,\n default_base_url: \"https://api.deepseek.com\",\n default_chat_path: \"/chat/completions\",\n models_path: Some(\"/models\"),\n default_model: \"deepseek-v4-flash\",\n suggested_models: DEEPSEEK_MODELS,\n auth_kind: AIAuthKind::BearerApiKey,\n capabilities: AIProviderCapabilities {\n supports_model_discovery: true,\n supports_response_format_json_object: true,\n supports_json_schema: false,\n supports_thinking: true,\n supports_thinking_toggle: true,\n supports_reasoning_effort: true,\n supports_usage: true,\n supports_streaming: true,\n },\n parameter_profile: AIParameterProfile {\n temperature_min: 0.0,\n temperature_max: 2.0,\n default_temperature: 0.0,\n max_output_tokens: Some(8192),\n token_parameter: AITokenParameter::MaxTokens,\n thinking_strategy: AIThinkingStrategy::DeepSeekThinkingObject,\n },\n response_profile: OPENAI_RESPONSE_PROFILE,\n endpoint_variants: EMPTY_ENDPOINTS,\n api_key_env_hint: \"DEEPSEEK_API_KEY\",\n docs_url: Some(\"https://api-docs.deepseek.com/\"),\n },\n AIProviderDescriptor {\n id: AIProviderPresetId::Kimi,\n label: \"Kimi / Moonshot\",\n provider_kind: AIProviderKind::OpenAICompatible,\n default_base_url: \"https://api.moonshot.ai/v1\",\n default_chat_path: \"/chat/completions\",\n models_path: Some(\"/models\"),\n default_model: \"kimi-k2.6\",\n suggested_models: KIMI_MODELS,\n auth_kind: AIAuthKind::BearerApiKey,\n capabilities: AIProviderCapabilities {\n supports_model_discovery: true,\n supports_response_format_json_object: true,\n supports_json_schema: false,\n supports_thinking: true,\n supports_thinking_toggle: true,\n supports_reasoning_effort: false,\n supports_usage: true,\n supports_streaming: true,\n },\n parameter_profile: AIParameterProfile {\n temperature_min: 0.0,\n temperature_max: 1.0,\n default_temperature: 0.0,\n max_output_tokens: Some(32768),\n token_parameter: AITokenParameter::MaxTokens,\n thinking_strategy: AIThinkingStrategy::GenericThinkingObject,\n },\n response_profile: OPENAI_RESPONSE_PROFILE,\n endpoint_variants: EMPTY_ENDPOINTS,\n api_key_env_hint: \"MOONSHOT_API_KEY\",\n docs_url: Some(\"https://platform.kimi.ai/docs\"),\n },\n AIProviderDescriptor {\n id: AIProviderPresetId::QwenDashScope,\n label: \"Qwen / 阿里云百炼\",\n provider_kind: AIProviderKind::OpenAICompatible,\n default_base_url: \"https://dashscope.aliyuncs.com/compatible-mode/v1\",\n default_chat_path: \"/chat/completions\",\n models_path: Some(\"/models\"),\n default_model: \"qwen-plus\",\n suggested_models: QWEN_MODELS,\n auth_kind: AIAuthKind::BearerApiKey,\n capabilities: QWEN_CAPABILITIES,\n parameter_profile: AIParameterProfile {\n temperature_min: 0.0,\n temperature_max: 2.0,\n default_temperature: 0.0,\n max_output_tokens: Some(32768),\n token_parameter: AITokenParameter::MaxTokens,\n thinking_strategy: AIThinkingStrategy::EnableThinkingBoolean,\n },\n response_profile: OPENAI_RESPONSE_PROFILE,\n endpoint_variants: DASHSCOPE_ENDPOINTS,\n api_key_env_hint: \"DASHSCOPE_API_KEY\",\n docs_url: Some(\"https://help.aliyun.com/model-studio/\"),\n },\n AIProviderDescriptor {\n id: AIProviderPresetId::ZhipuGlm,\n label: \"智谱 GLM\",\n provider_kind: AIProviderKind::OpenAICompatible,\n default_base_url: \"https://open.bigmodel.cn/api/paas/v4\",\n default_chat_path: \"/chat/completions\",\n models_path: None,\n default_model: \"glm-5.2\",\n suggested_models: GLM_MODELS,\n auth_kind: AIAuthKind::BearerApiKey,\n capabilities: AIProviderCapabilities {\n supports_model_discovery: false,\n supports_response_format_json_object: true,\n supports_json_schema: false,\n supports_thinking: true,\n supports_thinking_toggle: true,\n supports_reasoning_effort: true,\n supports_usage: true,\n supports_streaming: true,\n },\n parameter_profile: AIParameterProfile {\n temperature_min: 0.0,\n temperature_max: 2.0,\n default_temperature: 0.0,\n max_output_tokens: Some(32768),\n token_parameter: AITokenParameter::MaxTokens,\n thinking_strategy: AIThinkingStrategy::GenericThinkingObject,\n },\n response_profile: OPENAI_RESPONSE_PROFILE,\n endpoint_variants: EMPTY_ENDPOINTS,\n api_key_env_hint: \"ZHIPUAI_API_KEY\",\n docs_url: Some(\"https://docs.bigmodel.cn/\"),\n },\n AIProviderDescriptor {\n id: AIProviderPresetId::DoubaoArk,\n label: \"豆包 / 火山方舟\",\n provider_kind: AIProviderKind::OpenAICompatible,\n default_base_url: \"https://ark.cn-beijing.volces.com/api/v3\",\n default_chat_path: \"/chat/completions\",\n models_path: Some(\"/models\"),\n default_model: \"\",\n suggested_models: DOUBAO_MODELS,\n auth_kind: AIAuthKind::BearerApiKey,\n capabilities: PROMPT_ONLY,\n parameter_profile: AIParameterProfile {\n temperature_min: 0.0,\n temperature_max: 2.0,\n default_temperature: 0.0,\n max_output_tokens: Some(32768),\n token_parameter: AITokenParameter::MaxTokens,\n thinking_strategy: AIThinkingStrategy::PromptOnly,\n },\n response_profile: OPENAI_RESPONSE_PROFILE,\n endpoint_variants: EMPTY_ENDPOINTS,\n api_key_env_hint: \"ARK_API_KEY\",\n docs_url: Some(\"https://www.volcengine.com/docs/82379\"),\n },\n AIProviderDescriptor {\n id: AIProviderPresetId::Minimax,\n label: \"MiniMax\",\n provider_kind: AIProviderKind::OpenAICompatible,\n default_base_url: \"https://api.minimaxi.com/v1\",\n default_chat_path: \"/chat/completions\",\n models_path: Some(\"/models\"),\n default_model: \"MiniMax-M2.5\",\n suggested_models: MINIMAX_MODELS,\n auth_kind: AIAuthKind::BearerApiKey,\n capabilities: AIProviderCapabilities {\n supports_model_discovery: true,\n supports_response_format_json_object: true,\n supports_json_schema: false,\n supports_thinking: true,\n supports_thinking_toggle: true,\n supports_reasoning_effort: false,\n supports_usage: true,\n supports_streaming: true,\n },\n parameter_profile: AIParameterProfile {\n temperature_min: 0.0,\n temperature_max: 1.0,\n default_temperature: 0.0,\n max_output_tokens: Some(32768),\n token_parameter: AITokenParameter::MaxCompletionTokens,\n thinking_strategy: AIThinkingStrategy::MiniMaxReasoningSplit,\n },\n response_profile: MINIMAX_RESPONSE_PROFILE,\n endpoint_variants: EMPTY_ENDPOINTS,\n api_key_env_hint: \"MINIMAX_API_KEY\",\n docs_url: Some(\"https://platform.minimaxi.com/document\"),\n },\n AIProviderDescriptor {\n id: AIProviderPresetId::Hunyuan,\n label: \"腾讯混元\",\n provider_kind: AIProviderKind::OpenAICompatible,\n default_base_url: \"https://api.hunyuan.cloud.tencent.com/v1\",\n default_chat_path: \"/chat/completions\",\n models_path: Some(\"/models\"),\n default_model: \"hunyuan-turbos\",\n suggested_models: HUNYUAN_MODELS,\n auth_kind: AIAuthKind::BearerApiKey,\n capabilities: PROMPT_ONLY,\n parameter_profile: DEFAULT_OPENAI_PARAMETERS,\n response_profile: OPENAI_RESPONSE_PROFILE,\n endpoint_variants: EMPTY_ENDPOINTS,\n api_key_env_hint: \"HUNYUAN_API_KEY\",\n docs_url: Some(\"https://cloud.tencent.com/document/product/1729\"),\n },\n AIProviderDescriptor {\n id: AIProviderPresetId::Siliconflow,\n label: \"SiliconFlow / 硅基流动\",\n provider_kind: AIProviderKind::OpenAICompatible,\n default_base_url: \"https://api.siliconflow.cn/v1\",\n default_chat_path: \"/chat/completions\",\n models_path: Some(\"/models\"),\n default_model: \"\",\n suggested_models: &[],\n auth_kind: AIAuthKind::BearerApiKey,\n capabilities: PROMPT_ONLY,\n parameter_profile: DEFAULT_OPENAI_PARAMETERS,\n response_profile: OPENAI_RESPONSE_PROFILE,\n endpoint_variants: EMPTY_ENDPOINTS,\n api_key_env_hint: \"SILICONFLOW_API_KEY\",\n docs_url: Some(\"https://docs.siliconflow.cn/\"),\n },\n AIProviderDescriptor {\n id: AIProviderPresetId::BaiduQianfan,\n label: \"百度千帆 / 文心\",\n provider_kind: AIProviderKind::OpenAICompatible,\n default_base_url: \"https://qianfan.baidubce.com/v2\",\n default_chat_path: \"/chat/completions\",\n models_path: None,\n default_model: \"ernie-4.5-turbo-32k\",\n suggested_models: &[\"ernie-4.5-turbo-32k\", \"ernie-speed-128k\"],\n auth_kind: AIAuthKind::BearerApiKey,\n capabilities: PROMPT_ONLY,\n parameter_profile: DEFAULT_OPENAI_PARAMETERS,\n response_profile: OPENAI_RESPONSE_PROFILE,\n endpoint_variants: EMPTY_ENDPOINTS,\n api_key_env_hint: \"QIANFAN_API_KEY\",\n docs_url: Some(\"https://cloud.baidu.com/doc/WENXINWORKSHOP/\"),\n },\n AIProviderDescriptor {\n id: AIProviderPresetId::Baichuan,\n label: \"百川智能(兼容入口)\",\n provider_kind: AIProviderKind::OpenAICompatible,\n default_base_url: \"\",\n default_chat_path: \"/chat/completions\",\n models_path: None,\n default_model: \"\",\n suggested_models: &[\"Baichuan4-Turbo\"],\n auth_kind: AIAuthKind::BearerApiKey,\n capabilities: PROMPT_ONLY,\n parameter_profile: DEFAULT_OPENAI_PARAMETERS,\n response_profile: OPENAI_RESPONSE_PROFILE,\n endpoint_variants: EMPTY_ENDPOINTS,\n api_key_env_hint: \"BAICHUAN_API_KEY\",\n docs_url: Some(\"https://platform.baichuan-ai.com/docs/api\"),\n },\n AIProviderDescriptor {\n id: AIProviderPresetId::StepFun,\n label: \"阶跃星辰 / StepFun\",\n provider_kind: AIProviderKind::OpenAICompatible,\n default_base_url: \"https://api.stepfun.com/v1\",\n default_chat_path: \"/chat/completions\",\n models_path: Some(\"/models\"),\n default_model: \"step-3.5-flash\",\n suggested_models: STEPFUN_MODELS,\n auth_kind: AIAuthKind::BearerApiKey,\n capabilities: PROMPT_ONLY,\n parameter_profile: DEFAULT_OPENAI_PARAMETERS,\n response_profile: OPENAI_RESPONSE_PROFILE,\n endpoint_variants: EMPTY_ENDPOINTS,\n api_key_env_hint: \"STEPFUN_API_KEY\",\n docs_url: Some(\"https://platform.stepfun.com/docs\"),\n },\n AIProviderDescriptor {\n id: AIProviderPresetId::Yi,\n label: \"零一万物 / Yi(兼容平台)\",\n provider_kind: AIProviderKind::OpenAICompatible,\n default_base_url: \"\",\n default_chat_path: \"/chat/completions\",\n models_path: None,\n default_model: \"\",\n suggested_models: &[\"yi-large\"],\n auth_kind: AIAuthKind::BearerApiKey,\n capabilities: PROMPT_ONLY,\n parameter_profile: DEFAULT_OPENAI_PARAMETERS,\n response_profile: OPENAI_RESPONSE_PROFILE,\n endpoint_variants: EMPTY_ENDPOINTS,\n api_key_env_hint: \"YI_API_KEY\",\n docs_url: Some(\"https://platform.lingyiwanwu.com/\"),\n },\n AIProviderDescriptor {\n id: AIProviderPresetId::CustomOpenAICompatible,\n label: \"自定义 OpenAI-compatible\",\n provider_kind: AIProviderKind::OpenAICompatible,\n default_base_url: \"\",\n default_chat_path: \"/chat/completions\",\n models_path: Some(\"/models\"),\n default_model: \"\",\n suggested_models: &[],\n auth_kind: AIAuthKind::BearerApiKey,\n capabilities: PROMPT_ONLY,\n parameter_profile: DEFAULT_OPENAI_PARAMETERS,\n response_profile: OPENAI_RESPONSE_PROFILE,\n endpoint_variants: EMPTY_ENDPOINTS,\n api_key_env_hint: \"OPENAI_COMPATIBLE_API_KEY\",\n docs_url: None,\n },\n AIProviderDescriptor {\n id: AIProviderPresetId::Ollama,\n label: \"Ollama 本地模型\",\n provider_kind: AIProviderKind::Ollama,\n default_base_url: \"http://localhost:11434\",\n default_chat_path: \"/api/chat\",\n models_path: Some(\"/api/tags\"),\n default_model: \"qwen3:8b\",\n suggested_models: OLLAMA_MODELS,\n auth_kind: AIAuthKind::None,\n capabilities: AIProviderCapabilities {\n supports_model_discovery: true,\n supports_response_format_json_object: false,\n supports_json_schema: false,\n supports_thinking: true,\n supports_thinking_toggle: true,\n supports_reasoning_effort: false,\n supports_usage: true,\n supports_streaming: true,\n },\n parameter_profile: AIParameterProfile {\n temperature_min: 0.0,\n temperature_max: 2.0,\n default_temperature: 0.0,\n max_output_tokens: Some(32768),\n token_parameter: AITokenParameter::MaxTokens,\n thinking_strategy: AIThinkingStrategy::PromptOnly,\n },\n response_profile: OPENAI_RESPONSE_PROFILE,\n endpoint_variants: EMPTY_ENDPOINTS,\n api_key_env_hint: \"\",\n docs_url: Some(\"https://github.com/ollama/ollama/blob/main/docs/api.md\"),\n },\n];\n\npub fn provider_registry() -> &'static [AIProviderDescriptor] {\n PROVIDER_REGISTRY\n}\n\npub fn provider_descriptor(id: AIProviderPresetId) -> Option<&'static AIProviderDescriptor> {\n PROVIDER_REGISTRY\n .iter()\n .find(|descriptor| descriptor.id == id)\n}\n\n#[cfg(test)]\nmod tests {\n use super::*;\n\n #[test]\n fn registry_has_one_descriptor_for_each_supported_preset() {\n let expected = [\n AIProviderPresetId::DeepSeek,\n AIProviderPresetId::Kimi,\n AIProviderPresetId::QwenDashScope,\n AIProviderPresetId::ZhipuGlm,\n AIProviderPresetId::DoubaoArk,\n AIProviderPresetId::Minimax,\n AIProviderPresetId::Hunyuan,\n AIProviderPresetId::Siliconflow,\n AIProviderPresetId::BaiduQianfan,\n AIProviderPresetId::Baichuan,\n AIProviderPresetId::StepFun,\n AIProviderPresetId::Yi,\n AIProviderPresetId::CustomOpenAICompatible,\n AIProviderPresetId::Ollama,\n ];\n assert_eq!(provider_registry().len(), expected.len());\n for id in expected {\n assert_eq!(\n provider_registry()\n .iter()\n .filter(|item| item.id == id)\n .count(),\n 1\n );\n }\n }\n\n #[test]\n fn registry_snapshots_provider_specific_protocol_parameters() {\n let deepseek = provider_descriptor(AIProviderPresetId::DeepSeek).expect(\"DeepSeek\");\n assert_eq!(deepseek.default_base_url, \"https://api.deepseek.com\");\n assert!(deepseek.capabilities.supports_response_format_json_object);\n assert_eq!(\n deepseek.parameter_profile.thinking_strategy,\n AIThinkingStrategy::DeepSeekThinkingObject\n );\n\n let qwen = provider_descriptor(AIProviderPresetId::QwenDashScope).expect(\"Qwen\");\n assert!(qwen.capabilities.supports_response_format_json_object);\n assert_eq!(\n qwen.parameter_profile.thinking_strategy,\n AIThinkingStrategy::EnableThinkingBoolean\n );\n\n let minimax = provider_descriptor(AIProviderPresetId::Minimax).expect(\"MiniMax\");\n assert_eq!(\n minimax.parameter_profile.token_parameter,\n AITokenParameter::MaxCompletionTokens\n );\n assert_eq!(\n minimax.parameter_profile.thinking_strategy,\n AIThinkingStrategy::MiniMaxReasoningSplit\n );\n\n let qianfan = provider_descriptor(AIProviderPresetId::BaiduQianfan).expect(\"Qianfan\");\n assert_eq!(qianfan.auth_kind, AIAuthKind::BearerApiKey);\n assert_eq!(qianfan.default_base_url, \"https://qianfan.baidubce.com/v2\");\n }\n}\n"}}, "src-tauri/src/ai/openai_compatible.rs": {"9ea69d29143b994c8632747ab647f59637dfe324": {"t": 1785746532.3158052, "n": 53571, "h": "8d196fc44ad402aa", "c": "use std::time::{Duration, Instant};\n\nuse reqwest::blocking::Client;\nuse serde_json::{json, Map, Value};\n\nuse super::{\n presets::{provider_preset, AIProviderPreset},\n prompts::{clean_ai_json_text, extract_first_json_value},\n provider::{AIProvider, AIProviderError},\n registry::{AIAuthKind, AIThinkingStrategy, AITokenParameter},\n schema::{\n AIChatMessage, AIChatRequest, AIConnectionTestResult, AIModelInfo, AIProviderOptions,\n AIProviderPresetId,\n },\n settings::AISettings,\n trace::{\n now_iso, record_trace, update_trace_with_secrets, AIRequestTrace, AITraceContext,\n AITraceMode, AITraceOperation, AITraceRequest, AITraceResponse, AITraceUpdate,\n AITraceUsage, SecretRedactor,\n },\n};\n\ntype ChatBody = (Map<String, Value>, bool, Option<String>);\n\npub struct OpenAICompatibleProvider {\n settings: AISettings,\n preset: AIProviderPreset,\n client: Option<Client>,\n}\n\n#[derive(Debug, Clone)]\npub struct AIRawProviderResponse {\n pub status: u16,\n pub response_text: String,\n pub request_used_response_format: bool,\n pub request_used_thinking_field: Option<String>,\n pub response_summary: String,\n pub trace_id: Option<String>,\n pub(crate) pending_trace: Option<AIRequestTrace>,\n}\n\n#[derive(Debug, Clone, serde::Serialize)]\npub struct AIDebugExtractResult {\n pub finish_reason: Option<String>,\n pub message_keys: Vec<String>,\n pub message_content: Option<String>,\n pub reasoning_content: Option<String>,\n pub output_text: Option<String>,\n pub text: Option<String>,\n pub extracted_content: Option<String>,\n pub parse_error: Option<String>,\n}\n\nimpl OpenAICompatibleProvider {\n pub fn new(settings: AISettings) -> Self {\n let mut preset = provider_preset(settings.preset)\n .or_else(|| provider_preset(AIProviderPresetId::CustomOpenAICompatible))\n .expect(\"custom OpenAI-compatible preset exists\");\n if let Some(profile) = settings\n .active_custom_profile_id\n .as_deref()\n .and_then(|profile_id| {\n settings\n .custom_profiles\n .iter()\n .find(|profile| profile.id == profile_id)\n })\n {\n preset.capabilities.supports_response_format_json_object =\n profile.supports_response_format;\n preset.capabilities.supports_thinking = profile.supports_thinking;\n preset.capabilities.supports_thinking_toggle = profile.supports_thinking;\n preset.capabilities.supports_reasoning_effort = profile\n .thinking_parameter\n .eq_ignore_ascii_case(\"reasoning_effort\");\n preset.supports_response_format = profile.supports_response_format;\n preset.supports_json_mode = profile.supports_response_format;\n preset.supports_thinking = profile.supports_thinking;\n preset.supports_reasoning_effort = profile\n .thinking_parameter\n .eq_ignore_ascii_case(\"reasoning_effort\");\n preset.parameter_profile.token_parameter = if profile\n .token_parameter\n .eq_ignore_ascii_case(\"max_completion_tokens\")\n {\n AITokenParameter::MaxCompletionTokens\n } else {\n AITokenParameter::MaxTokens\n };\n preset.parameter_profile.thinking_strategy =\n match profile.thinking_parameter.to_ascii_lowercase().as_str() {\n \"none\" => AIThinkingStrategy::None,\n \"enable_thinking\" | \"boolean\" => AIThinkingStrategy::EnableThinkingBoolean,\n \"reasoning_effort\" => AIThinkingStrategy::ReasoningEffort,\n \"minimax_reasoning_split\" | \"reasoning_split\" => {\n AIThinkingStrategy::MiniMaxReasoningSplit\n }\n \"prompt_only\" => AIThinkingStrategy::PromptOnly,\n _ => AIThinkingStrategy::GenericThinkingObject,\n };\n }\n let client = Client::builder()\n .timeout(Duration::from_secs(settings.timeout_seconds.max(1)))\n .redirect(reqwest::redirect::Policy::none())\n .build()\n .ok();\n Self {\n settings,\n preset,\n client,\n }\n }\n\n fn request_url(&self) -> Result<String, AIProviderError> {\n join_base_url_and_chat_path(&self.settings.base_url, &self.settings.chat_path)\n }\n\n fn client(&self) -> Result<&Client, AIProviderError> {\n self.client\n .as_ref()\n .ok_or_else(|| self.error(\"failed to build AI HTTP client\"))\n }\n\n fn authorize_request(\n &self,\n builder: reqwest::blocking::RequestBuilder,\n ) -> reqwest::blocking::RequestBuilder {\n let api_key = self.settings.api_key.trim();\n if api_key.is_empty() {\n return builder;\n }\n match self.preset.auth_kind {\n AIAuthKind::BearerApiKey | AIAuthKind::QianfanAkSk => builder.bearer_auth(api_key),\n AIAuthKind::ApiKeyHeader => builder.header(\"X-API-Key\", api_key),\n AIAuthKind::None => builder,\n }\n }\n\n fn trace_secrets(&self, request: &AIChatRequest) -> Vec<String> {\n let mut secrets = vec![self.settings.api_key.clone()];\n if let Some(context) = request.provider_options.trace_context.as_ref() {\n secrets.extend(context.redaction_secrets.iter().cloned());\n }\n secrets\n }\n\n fn error(&self, message: impl Into<String>) -> AIProviderError {\n AIProviderError::new(redact_api_key(&message.into(), &self.settings.api_key))\n }\n\n pub fn send_chat_request_raw(\n &self,\n request: AIChatRequest,\n ) -> Result<AIRawProviderResponse, AIProviderError> {\n let started = Instant::now();\n let url = self.request_url()?;\n let (body, request_used_response_format, request_used_thinking_field) =\n self.build_chat_body(&request)?;\n let trace_mode = self.settings.diagnostics_mode;\n let mut trace = self.build_trace(\n &request,\n &body,\n &url,\n request_used_response_format,\n request_used_thinking_field.clone(),\n );\n\n let mut builder = self\n .client()?\n .post(&url)\n .header(reqwest::header::CONTENT_TYPE, \"application/json\");\n builder = self.authorize_request(builder);\n\n let response = match builder.json(&Value::Object(body)).send() {\n Ok(response) => response,\n Err(error) => {\n let message = self.error(format!(\"AI request failed: {error}\"));\n trace.elapsed_ms = started.elapsed().as_millis();\n trace.parse_stage = \"transport_error\".to_string();\n trace.error_code = Some(\"transport_error\".to_string());\n trace.error_message = Some(message.to_string());\n record_trace(trace_mode, trace, true);\n return Err(message);\n }\n };\n let status = response.status();\n if status.is_redirection() {\n let message = self.error(format!(\n \"AI provider redirect rejected: HTTP {}\",\n status.as_u16()\n ));\n trace.elapsed_ms = started.elapsed().as_millis();\n trace.response.http_status = Some(status.as_u16());\n trace.parse_stage = \"transport_error\".to_string();\n trace.error_code = Some(\"redirect_rejected\".to_string());\n trace.error_message = Some(message.to_string());\n record_trace(trace_mode, trace, true);\n return Err(message);\n }\n let response_text = response\n .text()\n .map_err(|error| self.error(format!(\"failed to read AI response: {error}\")))?;\n let trace_secrets = self.trace_secrets(&request);\n let redactor = SecretRedactor::new(trace_secrets.iter().map(String::as_str));\n let (redacted_response, raw_truncated) = redactor.redact_optional_text(\n Some(&response_text),\n super::trace::MAX_RAW_PROVIDER_RESPONSE_CHARS,\n );\n let redacted_response = redacted_response.unwrap_or_default();\n let response_summary = serde_json::from_str::<Value>(&response_text)\n .map(|value| summarize_provider_response(&value))\n .unwrap_or_else(|error| format!(\"provider response summary: invalid_json={error}\"));\n\n trace.elapsed_ms = started.elapsed().as_millis();\n trace.response = response_trace_metadata(status.as_u16(), &response_text);\n trace.raw_provider_response = Some(redacted_response.clone());\n trace.truncated |= raw_truncated;\n trace.parse_stage = if status.is_success() {\n \"provider_response\".to_string()\n } else {\n \"http_error\".to_string()\n };\n if !status.is_success() {\n trace.error_code = Some(format!(\"http_{}\", status.as_u16()));\n trace.error_message = Some(format!(\n \"AI provider returned HTTP {}: {}\",\n status.as_u16(),\n short_response(&redacted_response)\n ));\n }\n let trace_id = record_trace(trace_mode, trace.clone(), !status.is_success());\n let pending_trace = if trace_id.is_none() && !matches!(trace_mode, AITraceMode::Off) {\n Some(trace)\n } else {\n None\n };\n\n Ok(AIRawProviderResponse {\n status: status.as_u16(),\n response_text: redacted_response,\n request_used_response_format,\n request_used_thinking_field,\n response_summary,\n trace_id,\n pending_trace,\n })\n }\n\n fn build_trace(\n &self,\n request: &AIChatRequest,\n body: &Map<String, Value>,\n url: &str,\n request_used_response_format: bool,\n request_used_thinking_field: Option<String>,\n ) -> AIRequestTrace {\n let parsed_url = url::Url::parse(url).ok();\n let mut extra_body_keys = body\n .keys()\n .filter(|key| {\n !matches!(\n key.as_str(),\n \"model\"\n | \"messages\"\n | \"stream\"\n | \"temperature\"\n | \"max_tokens\"\n | \"max_completion_tokens\"\n | \"response_format\"\n | \"thinking\"\n | \"enable_thinking\"\n | \"reasoning_split\"\n | \"reasoning_effort\"\n )\n })\n .cloned()\n .collect::<Vec<_>>();\n extra_body_keys.sort();\n let context = request\n .provider_options\n .trace_context\n .clone()\n .unwrap_or_default();\n AIRequestTrace {\n trace_id: String::new(),\n job_id: context.job_id.clone(),\n batch_id: context.batch_id.clone(),\n started_at: now_iso(),\n elapsed_ms: 0,\n operation: context.operation,\n provider_id: format!(\"{:?}\", self.preset.id),\n provider_label: self.preset.label.to_string(),\n model: request.model.clone(),\n request: AITraceRequest {\n url_host: parsed_url\n .as_ref()\n .and_then(url::Url::host_str)\n .unwrap_or_default()\n .to_string(),\n path: parsed_url\n .as_ref()\n .map(|url| url.path().to_string())\n .unwrap_or_else(|| \"/\".to_string()),\n message_count: request.messages.len(),\n target_count: context.target_count,\n batch_size: context.batch_size,\n max_tokens: Some(request.max_tokens),\n temperature: body\n .get(\"temperature\")\n .and_then(Value::as_f64)\n .map(|temperature| temperature as f32),\n force_json: request.force_json,\n response_format: request_used_response_format.then(|| \"json_object\".to_string()),\n thinking_mode: request_used_thinking_field,\n extra_body_keys,\n },\n parse_stage: \"request_sent\".to_string(),\n ..AIRequestTrace::default()\n }\n }\n\n fn build_chat_body(&self, request: &AIChatRequest) -> Result<ChatBody, AIProviderError> {\n let mut body = Map::new();\n body.insert(\"model\".to_string(), json!(request.model));\n body.insert(\n \"messages\".to_string(),\n json!(messages_with_instructions(\n &request.messages,\n request.force_json,\n thinking_enabled(&self.settings, request),\n self.preset.id\n )),\n );\n let temperature = request.temperature.clamp(\n self.preset.parameter_profile.temperature_min as f32,\n self.preset.parameter_profile.temperature_max as f32,\n );\n body.insert(\"temperature\".to_string(), json!(temperature));\n let token_field = match self.preset.parameter_profile.token_parameter {\n AITokenParameter::MaxTokens => \"max_tokens\",\n AITokenParameter::MaxCompletionTokens => \"max_completion_tokens\",\n };\n body.insert(token_field.to_string(), json!(request.max_tokens));\n\n let response_format_enabled = request.provider_options.use_response_format.unwrap_or(\n self.preset\n .capabilities\n .supports_response_format_json_object,\n );\n if request.force_json && response_format_enabled {\n body.insert(\n \"response_format\".to_string(),\n json!({ \"type\": \"json_object\" }),\n );\n }\n\n // Advanced fields may extend the request, but core request semantics stay authoritative.\n merge_extra_body(\n &mut body,\n self.settings.extra_body_json.as_deref(),\n &self.settings.api_key,\n )\n .map_err(|error| self.error(error.to_string()))?;\n merge_extra_body(\n &mut body,\n request.provider_options.extra_body_json.as_deref(),\n &self.settings.api_key,\n )\n .map_err(|error| self.error(error.to_string()))?;\n\n let enable_thinking = thinking_enabled(&self.settings, request);\n if enable_thinking && self.preset.supports_reasoning_effort {\n if let Some(reasoning_effort) = request\n .provider_options\n .reasoning_effort\n .as_deref()\n .or(self.settings.reasoning_effort.as_deref())\n .filter(|value| !value.trim().is_empty())\n {\n body.insert(\"reasoning_effort\".to_string(), json!(reasoning_effort));\n }\n }\n match self.preset.parameter_profile.thinking_strategy {\n AIThinkingStrategy::DeepSeekThinkingObject\n if self.preset.capabilities.supports_thinking_toggle =>\n {\n body.insert(\n \"thinking\".to_string(),\n json!({ \"type\": if enable_thinking { \"enabled\" } else { \"disabled\" } }),\n );\n }\n AIThinkingStrategy::GenericThinkingObject\n if self.preset.capabilities.supports_thinking_toggle =>\n {\n body.insert(\n \"thinking\".to_string(),\n json!({ \"type\": if enable_thinking { \"enabled\" } else { \"disabled\" } }),\n );\n }\n AIThinkingStrategy::EnableThinkingBoolean\n if self.preset.capabilities.supports_thinking_toggle =>\n {\n body.insert(\"enable_thinking\".to_string(), json!(enable_thinking));\n }\n AIThinkingStrategy::MiniMaxReasoningSplit => {\n body.insert(\"reasoning_split\".to_string(), json!(enable_thinking));\n }\n _ => {}\n }\n\n let request_used_thinking_field = body.get(\"thinking\").map(thinking_field_text);\n let request_used_thinking_field = request_used_thinking_field.or_else(|| {\n body.get(\"enable_thinking\").map(|value| {\n value\n .as_bool()\n .map(|enabled| enabled.to_string())\n .unwrap_or_else(|| value.to_string())\n })\n });\n Ok((\n body,\n request.force_json && response_format_enabled,\n request_used_thinking_field,\n ))\n }\n}\n\nimpl AIProvider for OpenAICompatibleProvider {\n fn chat_json(&self, request: AIChatRequest) -> Result<String, AIProviderError> {\n let raw = self.send_chat_request_raw(request.clone())?;\n if !(200..300).contains(&raw.status) {\n let mut message = format!(\n \"AI provider returned HTTP {}: {}\",\n raw.status,\n short_response(&raw.response_text)\n );\n if self.preset.id == AIProviderPresetId::DeepSeek\n && !thinking_enabled(&self.settings, &request)\n {\n message.push_str(\n \" If DeepSeek rejects thinking disabled, use a non-thinking model or a compatible legacy model name.\",\n );\n }\n let error = self.error(message);\n if let Some(trace_id) = raw.trace_id.as_deref() {\n let trace_secrets = self.trace_secrets(&request);\n update_trace_with_secrets(\n trace_id,\n AITraceUpdate {\n parse_stage: Some(\"http_error\".to_string()),\n error_code: Some(format!(\"http_{}\", raw.status)),\n error_message: Some(error.to_string()),\n ..AITraceUpdate::default()\n },\n trace_secrets.iter().map(String::as_str),\n );\n }\n return Err(error);\n }\n\n match parse_openai_content(&raw.response_text) {\n Ok(content) => {\n let cleaned_json_text = clean_ai_json_text(&content);\n let parsed_json = serde_json::from_str::<Value>(&cleaned_json_text)\n .ok()\n .or_else(|| {\n extract_first_json_value(&content)\n .and_then(|value| serde_json::from_str::<Value>(&value).ok())\n });\n if let Some(trace_id) = raw.trace_id.as_deref() {\n let trace_secrets = self.trace_secrets(&request);\n update_trace_with_secrets(\n trace_id,\n AITraceUpdate {\n extracted_content: Some(content.clone()),\n cleaned_json_text: Some(cleaned_json_text),\n parsed_json,\n parse_stage: Some(\"extracted_content\".to_string()),\n ..AITraceUpdate::default()\n },\n trace_secrets.iter().map(String::as_str),\n );\n }\n Ok(content)\n }\n Err(error) => {\n let error = self.error(error);\n let trace_secrets = self.trace_secrets(&request);\n if let Some(trace_id) = raw.trace_id.as_deref() {\n update_trace_with_secrets(\n trace_id,\n AITraceUpdate {\n parse_stage: Some(\"parse_error\".to_string()),\n error_code: Some(\"response_parse_error\".to_string()),\n error_message: Some(error.to_string()),\n ..AITraceUpdate::default()\n },\n trace_secrets.iter().map(String::as_str),\n );\n } else if let Some(mut trace) = raw.pending_trace {\n trace.parse_stage = \"parse_error\".to_string();\n trace.error_code = Some(\"response_parse_error\".to_string());\n trace.error_message = Some(\n SecretRedactor::new(trace_secrets.iter().map(String::as_str))\n .redact_text(&error.to_string()),\n );\n record_trace(self.settings.diagnostics_mode, trace, true);\n }\n Err(error)\n }\n }\n }\n\n fn test_connection(&self) -> Result<AIConnectionTestResult, AIProviderError> {\n let mut prompt = \"Return exactly this JSON and nothing else: {\\\"ok\\\":true}\\nDo not output Markdown.\\nDo not output reasoning.\\nDo not output <think>.\\nDo not explain.\".to_string();\n if self.preset.id == AIProviderPresetId::DeepSeek && !self.settings.enable_thinking {\n prompt.push_str(\"\\nUse non-thinking mode and only return final content.\");\n }\n let test_max_tokens = self.settings.max_tokens.clamp(512, 4096);\n let content = self.chat_json(AIChatRequest {\n messages: vec![AIChatMessage {\n role: \"user\".to_string(),\n content: prompt,\n }],\n model: self.settings.model.clone(),\n temperature: 0.0,\n max_tokens: test_max_tokens,\n force_json: true,\n provider_options: AIProviderOptions {\n trace_context: Some(AITraceContext {\n operation: AITraceOperation::ConnectionTest,\n ..Default::default()\n }),\n ..Default::default()\n },\n })?;\n Ok(AIConnectionTestResult {\n ok: true,\n message: format!(\"AI provider responded: {}\", short_response(&content)),\n model: Some(self.settings.model.clone()),\n provider: Some(self.settings.provider),\n preset: Some(self.settings.preset),\n elapsed_ms: 0,\n })\n }\n\n fn discover_models(&self) -> Result<Vec<AIModelInfo>, AIProviderError> {\n let models_path = self\n .settings\n .models_path\n .as_deref()\n .or(self.preset.models_path)\n .filter(|path| !path.trim().is_empty())\n .ok_or_else(|| {\n self.error(\"This provider does not expose an OpenAI-compatible models endpoint.\")\n })?;\n let url = join_base_url_and_chat_path(&self.settings.base_url, models_path)?;\n let mut builder = self\n .client()?\n .get(url)\n .header(reqwest::header::ACCEPT, \"application/json\");\n builder = self.authorize_request(builder);\n let response = builder\n .send()\n .map_err(|error| self.error(format!(\"AI model discovery request failed: {error}\")))?;\n let status = response.status();\n let text = response.text().map_err(|error| {\n self.error(format!(\n \"failed to read AI model discovery response: {error}\"\n ))\n })?;\n if !status.is_success() {\n return Err(self.error(format!(\n \"AI model discovery returned HTTP {}: {}\",\n status.as_u16(),\n short_response(&redact_api_key(&text, &self.settings.api_key))\n )));\n }\n parse_model_list(&text).map_err(|error| self.error(error))\n }\n}\n\npub fn join_base_url_and_chat_path(\n base_url: &str,\n chat_path: &str,\n) -> Result<String, AIProviderError> {\n let base = base_url.trim().trim_end_matches('/');\n let path = chat_path.trim().trim_start_matches('/');\n if base.is_empty() {\n return Err(AIProviderError::new(\"AI provider base_url is empty.\"));\n }\n if path.is_empty() {\n return Err(AIProviderError::new(\"AI provider chat_path is empty.\"));\n }\n Ok(format!(\"{base}/{path}\"))\n}\n\nfn messages_with_instructions(\n messages: &[AIChatMessage],\n force_json: bool,\n enable_thinking: bool,\n preset_id: AIProviderPresetId,\n) -> Vec<AIChatMessage> {\n let mut output = Vec::new();\n let mut instructions = Vec::new();\n if force_json {\n instructions.push(\"Return JSON only; the final content must be one complete valid JSON object. Include the word JSON. Do not wrap JSON in markdown or code fences.\".to_string());\n }\n if !enable_thinking {\n instructions.push(\"Do not output thinking, reasoning traces, or explanations; return the final JSON content directly.\".to_string());\n }\n if preset_id == AIProviderPresetId::DeepSeek {\n instructions.push(\n \"DeepSeek legacy model names deepseek-chat and deepseek-reasoner may be deprecated; keep output compatible with the selected model.\".to_string(),\n );\n }\n if !instructions.is_empty() {\n output.push(AIChatMessage {\n role: \"system\".to_string(),\n content: instructions.join(\" \"),\n });\n }\n output.extend_from_slice(messages);\n output\n}\n\nfn thinking_enabled(settings: &AISettings, request: &AIChatRequest) -> bool {\n request\n .provider_options\n .enable_thinking\n .unwrap_or(settings.enable_thinking)\n}\n\nfn merge_extra_body(\n body: &mut Map<String, Value>,\n extra_body_json: Option<&str>,\n api_key: &str,\n) -> Result<(), AIProviderError> {\n let Some(extra_body_json) = extra_body_json.filter(|value| !value.trim().is_empty()) else {\n return Ok(());\n };\n let value = serde_json::from_str::<Value>(extra_body_json).map_err(|error| {\n AIProviderError::new(redact_api_key(\n &format!(\"AI extra_body_json must be a JSON object: {error}\"),\n api_key,\n ))\n })?;\n let Value::Object(extra) = value else {\n return Err(AIProviderError::new(\n \"AI extra_body_json must be a JSON object.\",\n ));\n };\n for (key, value) in extra {\n if matches!(\n key.as_str(),\n \"model\"\n | \"messages\"\n | \"stream\"\n | \"temperature\"\n | \"max_tokens\"\n | \"max_completion_tokens\"\n | \"response_format\"\n | \"thinking\"\n | \"reasoning_effort\"\n | \"enable_thinking\"\n | \"reasoning_split\"\n | \"reasoning\"\n ) {\n continue;\n }\n body.insert(key, value);\n }\n Ok(())\n}\n\nfn parse_openai_content(text: &str) -> Result<String, String> {\n let value = serde_json::from_str::<Value>(text)\n .map_err(|error| format!(\"AI provider returned invalid JSON: {error}\"))?;\n let summary = summarize_provider_response(&value);\n let finish_reason = response_finish_reason(&value);\n if matches!(finish_reason.as_deref(), Some(\"length\" | \"max_tokens\")) {\n return Err(format!(\n \"模型输出达到长度上限,JSON 被截断。AI provider finish_reason={}。请提高 max_tokens 或降低 batch size。 {summary}\",\n finish_reason.as_deref().unwrap_or(\"length\")\n ));\n }\n if matches!(finish_reason.as_deref(), Some(\"content_filter\" | \"safety\")) {\n return Err(format!(\n \"AI provider blocked the response because of content filtering (finish_reason={}). {summary}\",\n finish_reason.as_deref().unwrap_or(\"content_filter\")\n ));\n }\n\n let reasoning = response_reasoning_text(&value);\n let content = response_content_text(&value);\n match content {\n Some(content) if !content.trim().is_empty() => {\n validate_content_has_jsonish_text(strip_think_tags_if_needed(content), &summary)\n }\n Some(_) if !reasoning.trim().is_empty() => Err(format!(\n \"AI provider returned reasoning_content but empty content. Please disable Thinking or use a non-thinking model. {summary}\"\n )),\n Some(_) => Err(format!(\n \"AI provider returned empty message.content. JSON content is empty and should be retried. {summary}\"\n )),\n None if !reasoning.trim().is_empty() => Err(format!(\n \"AI provider returned reasoning_content but no final content. Please disable Thinking or use a non-thinking model. {summary}\"\n )),\n None => Err(format!(\n \"AI provider response did not contain message.content, message.output_text, or message.text. {summary}\"\n )),\n }\n}\n\nfn response_finish_reason(value: &Value) -> Option<String> {\n value\n .get(\"choices\")\n .and_then(Value::as_array)\n .and_then(|choices| choices.first())\n .and_then(|choice| choice.get(\"finish_reason\"))\n .or_else(|| value.get(\"finish_reason\"))\n .and_then(Value::as_str)\n .map(ToString::to_string)\n}\n\nfn response_content_text(value: &Value) -> Option<String> {\n let choice = value\n .get(\"choices\")\n .and_then(Value::as_array)\n .and_then(|choices| choices.first());\n let message = choice.and_then(|choice| choice.get(\"message\"));\n for candidate in [\n message.and_then(|message| message.get(\"content\")),\n message.and_then(|message| message.get(\"output_text\")),\n message.and_then(|message| message.get(\"text\")),\n choice.and_then(|choice| choice.get(\"text\")),\n value.get(\"output_text\"),\n value.get(\"content\"),\n value.get(\"output\").and_then(|output| output.get(\"text\")),\n value.get(\"output\").and_then(|output| output.get(\"content\")),\n ]\n .into_iter()\n .flatten()\n {\n if let Some(text) = content_value_to_text(candidate) {\n return Some(text);\n }\n }\n None\n}\n\nfn response_reasoning_text(value: &Value) -> String {\n let choice = value\n .get(\"choices\")\n .and_then(Value::as_array)\n .and_then(|choices| choices.first());\n let message = choice.and_then(|choice| choice.get(\"message\"));\n [\n message.and_then(|message| message.get(\"reasoning_content\")),\n message.and_then(|message| message.get(\"reasoning_details\")),\n choice.and_then(|choice| choice.get(\"reasoning_content\")),\n value.get(\"reasoning_content\"),\n value.get(\"reasoning_details\"),\n ]\n .into_iter()\n .flatten()\n .filter_map(content_value_to_text)\n .collect::<Vec<_>>()\n .join(\"\\n\")\n}\n\nfn response_trace_metadata(status: u16, text: &str) -> AITraceResponse {\n let Ok(value) = serde_json::from_str::<Value>(text) else {\n return AITraceResponse {\n http_status: Some(status),\n ..AITraceResponse::default()\n };\n };\n let choice = value\n .get(\"choices\")\n .and_then(Value::as_array)\n .and_then(|choices| choices.first());\n let message = choice\n .and_then(|choice| choice.get(\"message\"))\n .and_then(Value::as_object);\n let mut message_keys = message\n .map(|message| message.keys().cloned().collect::<Vec<_>>())\n .unwrap_or_default();\n message_keys.sort();\n let content_value = message\n .and_then(|message| message.get(\"content\"))\n .or_else(|| value.get(\"content\"));\n let reasoning_value = message\n .and_then(|message| message.get(\"reasoning_content\"))\n .or_else(|| message.and_then(|message| message.get(\"reasoning_details\")))\n .or_else(|| value.get(\"reasoning_content\"));\n let usage = value.get(\"usage\").map(|usage| AITraceUsage {\n prompt_tokens: usage_number(usage, &[\"prompt_tokens\", \"input_tokens\"]),\n completion_tokens: usage_number(usage, &[\"completion_tokens\", \"output_tokens\"]),\n total_tokens: usage_number(usage, &[\"total_tokens\"]),\n });\n AITraceResponse {\n http_status: Some(status),\n finish_reason: response_finish_reason(&value),\n message_keys,\n content_type: content_value.map(json_type_name).map(ToString::to_string),\n content_length: Some(value_text_len(content_value)),\n reasoning_content_length: Some(value_text_len(reasoning_value)),\n usage,\n }\n}\n\nfn usage_number(value: &Value, keys: &[&str]) -> Option<u64> {\n keys.iter()\n .find_map(|key| value.get(*key).and_then(Value::as_u64))\n}\n\nfn strip_think_tags_if_needed(content: String) -> String {\n let mut output = content;\n while let Some(start) = output.to_ascii_lowercase().find(\"<think>\") {\n let Some(relative_end) = output[start..].to_ascii_lowercase().find(\"</think>\") else {\n break;\n };\n let end = start + relative_end + \"</think>\".len();\n output.replace_range(start..end, \"\");\n }\n output.trim().to_string()\n}\n\npub fn debug_extract_openai_response(raw_response: &str) -> AIDebugExtractResult {\n let value = match serde_json::from_str::<Value>(raw_response) {\n Ok(value) => value,\n Err(error) => {\n return AIDebugExtractResult {\n finish_reason: None,\n message_keys: Vec::new(),\n message_content: None,\n reasoning_content: None,\n output_text: None,\n text: None,\n extracted_content: None,\n parse_error: Some(format!(\"provider raw response is not JSON: {error}\")),\n };\n }\n };\n let choice = value\n .get(\"choices\")\n .and_then(Value::as_array)\n .and_then(|choices| choices.first());\n let message = choice\n .and_then(|choice| choice.get(\"message\"))\n .and_then(Value::as_object);\n let mut message_keys = message\n .map(|message| message.keys().cloned().collect::<Vec<_>>())\n .unwrap_or_default();\n message_keys.sort();\n let message_content = message\n .and_then(|message| message.get(\"content\"))\n .and_then(content_value_to_text);\n let output_text = message\n .and_then(|message| message.get(\"output_text\"))\n .and_then(content_value_to_text);\n let text = message\n .and_then(|message| message.get(\"text\"))\n .and_then(content_value_to_text);\n let extracted_content = message_content\n .clone()\n .or_else(|| output_text.clone())\n .or_else(|| text.clone());\n\n AIDebugExtractResult {\n finish_reason: choice\n .and_then(|choice| choice.get(\"finish_reason\"))\n .and_then(Value::as_str)\n .map(ToString::to_string),\n message_keys,\n message_content,\n reasoning_content: message\n .and_then(|message| message.get(\"reasoning_content\"))\n .and_then(content_value_to_text),\n output_text,\n text,\n extracted_content,\n parse_error: message\n .is_none()\n .then(|| \"provider response did not contain choices[0].message\".to_string()),\n }\n}\n\npub(crate) fn summarize_provider_response(value: &Value) -> String {\n let choices = value.get(\"choices\").and_then(Value::as_array);\n let choice = choices.and_then(|items| items.first());\n let message = choice\n .and_then(|choice| choice.get(\"message\"))\n .and_then(Value::as_object);\n let mut message_keys = message\n .map(|message| message.keys().cloned().collect::<Vec<_>>())\n .unwrap_or_default();\n message_keys.sort();\n let content = message.and_then(|message| message.get(\"content\"));\n let reasoning_content = message.and_then(|message| message.get(\"reasoning_content\"));\n format!(\n \"provider response summary: has_choices={}; choice_count={}; finish_reason={}; message_keys={}; content_type={}; content_length={}; has_reasoning_content={}; reasoning_content_length={}\",\n choices.is_some(),\n choices.map_or(0, Vec::len),\n choice\n .and_then(|choice| choice.get(\"finish_reason\"))\n .map(json_type_or_string)\n .unwrap_or_else(|| \"missing\".to_string()),\n if message_keys.is_empty() {\n \"[]\".to_string()\n } else {\n format!(\"[{}]\", message_keys.join(\",\"))\n },\n content.map(json_type_name).unwrap_or(\"missing\"),\n value_text_len(content),\n reasoning_content.is_some(),\n value_text_len(reasoning_content),\n )\n}\n\nfn thinking_field_text(value: &Value) -> String {\n value\n .get(\"type\")\n .and_then(Value::as_str)\n .map(ToString::to_string)\n .unwrap_or_else(|| {\n serde_json::to_string(value).unwrap_or_else(|_| json_type_name(value).to_string())\n })\n}\n\nfn content_value_to_text(value: &Value) -> Option<String> {\n match value {\n Value::String(text) => Some(text.clone()),\n Value::Array(items) => {\n let mut output = String::new();\n for item in items {\n let Some(object) = item.as_object() else {\n continue;\n };\n if object.get(\"type\").and_then(Value::as_str) == Some(\"text\") {\n if let Some(text) = object.get(\"text\").and_then(Value::as_str) {\n output.push_str(text);\n }\n } else if let Some(text) = object\n .get(\"text\")\n .or_else(|| object.get(\"content\"))\n .or_else(|| object.get(\"value\"))\n .and_then(Value::as_str)\n {\n output.push_str(text);\n }\n }\n Some(output)\n }\n Value::Object(object) => object\n .get(\"text\")\n .or_else(|| object.get(\"content\"))\n .or_else(|| object.get(\"value\"))\n .and_then(Value::as_str)\n .map(ToString::to_string),\n _ => None,\n }\n}\n\nfn validate_content_has_jsonish_text(content: String, summary: &str) -> Result<String, String> {\n if content.contains('{') || content.contains('[') {\n Ok(content)\n } else {\n Err(format!(\n \"AI provider returned non-JSON message content. content_preview=\\\"{}\\\". {summary}\",\n preview_text(&content, 300)\n ))\n }\n}\n\nfn value_text_len(value: Option<&Value>) -> usize {\n value\n .and_then(content_value_to_text)\n .map(|text| text.chars().count())\n .unwrap_or(0)\n}\n\nfn json_type_or_string(value: &Value) -> String {\n value\n .as_str()\n .map(ToString::to_string)\n .unwrap_or_else(|| json_type_name(value).to_string())\n}\n\nfn json_type_name(value: &Value) -> &'static str {\n match value {\n Value::Null => \"null\",\n Value::Bool(_) => \"boolean\",\n Value::Number(_) => \"number\",\n Value::String(_) => \"string\",\n Value::Array(_) => \"array\",\n Value::Object(_) => \"object\",\n }\n}\n\nfn preview_text(text: &str, limit: usize) -> String {\n let mut preview = text\n .chars()\n .take(limit)\n .collect::<String>()\n .replace(['\\r', '\\n', '\\t'], \" \");\n if text.chars().count() > limit {\n preview.push_str(\"...\");\n }\n preview\n}\n\nfn short_response(text: &str) -> String {\n const LIMIT: usize = 500;\n if text.len() <= LIMIT {\n text.to_string()\n } else {\n format!(\"{}...\", &text[..LIMIT])\n }\n}\n\nfn redact_api_key(message: &str, api_key: &str) -> String {\n let trimmed = api_key.trim();\n if trimmed.is_empty() {\n message.to_string()\n } else {\n message.replace(trimmed, \"[redacted]\")\n }\n}\n\nfn parse_model_list(text: &str) -> Result<Vec<AIModelInfo>, String> {\n let value = serde_json::from_str::<Value>(text)\n .map_err(|error| format!(\"AI model discovery returned invalid JSON: {error}\"))?;\n let models = value\n .get(\"data\")\n .or_else(|| value.get(\"models\"))\n .unwrap_or(&value);\n let items = models\n .as_array()\n .ok_or_else(|| \"AI model discovery response did not contain a model array.\".to_string())?;\n let mut result = Vec::new();\n let mut seen = std::collections::HashSet::new();\n for item in items {\n let (id, owned_by) = match item {\n Value::String(id) => (id.trim().to_string(), None),\n Value::Object(object) => {\n let id = object\n .get(\"id\")\n .or_else(|| object.get(\"name\"))\n .and_then(Value::as_str)\n .unwrap_or_default()\n .trim()\n .to_string();\n let owned_by = object\n .get(\"owned_by\")\n .or_else(|| object.get(\"ownedBy\"))\n .and_then(Value::as_str)\n .map(ToString::to_string);\n (id, owned_by)\n }\n _ => (String::new(), None),\n };\n if !id.is_empty() && seen.insert(id.clone()) {\n result.push(AIModelInfo {\n id,\n owned_by,\n discovered: true,\n });\n }\n }\n Ok(result)\n}\n\n#[cfg(test)]\nmod tests {\n use super::*;\n use crate::ai::schema::AIProviderOptions;\n use std::{\n io::{Read, Write},\n net::TcpListener,\n thread,\n };\n\n #[test]\n fn extra_body_cannot_override_reserved_fields() {\n let mut body = Map::from_iter([\n (\"model\".to_string(), json!(\"trusted-model\")),\n (\n \"messages\".to_string(),\n json!([{\"role\":\"user\",\"content\":\"safe\"}]),\n ),\n (\"temperature\".to_string(), json!(0.2)),\n (\"max_tokens\".to_string(), json!(1024)),\n (\"response_format\".to_string(), json!({\"type\":\"json_object\"})),\n ]);\n\n merge_extra_body(\n &mut body,\n Some(r#\"{\"model\":\"attacker\",\"messages\":[],\"stream\":true,\"temperature\":2,\"max_tokens\":999999,\"max_completion_tokens\":999999,\"response_format\":{\"type\":\"text\"},\"thinking\":{\"type\":\"enabled\"},\"reasoning_effort\":\"high\",\"safe_extension\":true}\"#),\n \"\",\n )\n .expect(\"merge safe extension fields\");\n\n assert_eq!(body[\"model\"], \"trusted-model\");\n assert_eq!(body[\"messages\"][0][\"content\"], \"safe\");\n assert_eq!(body[\"temperature\"], 0.2);\n assert_eq!(body[\"max_tokens\"], 1024);\n assert_eq!(body[\"response_format\"][\"type\"], \"json_object\");\n for key in [\n \"stream\",\n \"max_completion_tokens\",\n \"thinking\",\n \"reasoning_effort\",\n ] {\n assert!(!body.contains_key(key), \"{key}\");\n }\n assert_eq!(body[\"safe_extension\"], true);\n }\n\n #[test]\n fn parse_openai_content_reads_normal_string_content() {\n let text =\n r#\"{\"choices\":[{\"finish_reason\":\"stop\",\"message\":{\"content\":\"{\\\"ok\\\":true}\"}}]}\"#;\n let content = parse_openai_content(text).expect(\"parse content\");\n assert_eq!(content, r#\"{\"ok\":true}\"#);\n }\n\n #[test]\n fn parse_openai_content_reports_empty_content() {\n let text = r#\"{\"choices\":[{\"finish_reason\":\"stop\",\"message\":{\"content\":\" \"}}]}\"#;\n let error = parse_openai_content(text).expect_err(\"empty content should fail\");\n assert!(error.contains(\"AI provider returned empty message.content.\"));\n assert!(error.contains(\"has_choices=true\"));\n assert!(error.contains(\"content_type=string\"));\n assert!(error.contains(\"content_length=2\"));\n }\n\n #[test]\n fn parse_openai_content_reports_reasoning_without_final_content() {\n let text = r#\"{\"choices\":[{\"finish_reason\":\"stop\",\"message\":{\"content\":\"\",\"reasoning_content\":\"thinking trace\"}}]}\"#;\n let error = parse_openai_content(text).expect_err(\"reasoning-only response should fail\");\n assert!(error.contains(\"AI provider returned reasoning_content but empty content.\"));\n assert!(error.contains(\"Please disable Thinking or use a non-thinking model.\"));\n assert!(error.contains(\"has_reasoning_content=true\"));\n assert!(error.contains(\"reasoning_content_length=14\"));\n }\n\n #[test]\n fn parse_openai_content_reads_array_text_content() {\n let text = r#\"{\"choices\":[{\"finish_reason\":\"stop\",\"message\":{\"content\":[{\"type\":\"text\",\"text\":\"{\\\"ok\\\":\"},{\"type\":\"text\",\"text\":\"true}\"}]}}]}\"#;\n let content = parse_openai_content(text).expect(\"parse text parts\");\n assert_eq!(content, r#\"{\"ok\":true}\"#);\n }\n\n #[test]\n fn parse_openai_content_reads_message_output_text() {\n let text =\n r#\"{\"choices\":[{\"finish_reason\":\"stop\",\"message\":{\"output_text\":\"{\\\"ok\\\":true}\"}}]}\"#;\n let content = parse_openai_content(text).expect(\"parse output_text\");\n assert_eq!(content, r#\"{\"ok\":true}\"#);\n }\n\n #[test]\n fn parse_openai_content_reads_message_text() {\n let text = r#\"{\"choices\":[{\"finish_reason\":\"stop\",\"message\":{\"text\":\"{\\\"ok\\\":true}\"}}]}\"#;\n let content = parse_openai_content(text).expect(\"parse text\");\n assert_eq!(content, r#\"{\"ok\":true}\"#);\n }\n\n #[test]\n fn model_discovery_parses_openai_data_and_deduplicates_ids() {\n let models = parse_model_list(\n r#\"{\"data\":[{\"id\":\"deepseek-v4-flash\",\"owned_by\":\"deepseek\"},{\"id\":\"deepseek-v4-flash\"},{\"id\":\"\"}]}\"#,\n )\n .expect(\"model list\");\n assert_eq!(models.len(), 1);\n assert_eq!(models[0].id, \"deepseek-v4-flash\");\n assert_eq!(models[0].owned_by.as_deref(), Some(\"deepseek\"));\n }\n\n #[test]\n fn deepseek_force_json_uses_json_object_response_format() {\n let provider = OpenAICompatibleProvider::new(AISettings::default());\n let (_, used_response_format, thinking) = provider\n .build_chat_body(&AIChatRequest {\n messages: vec![AIChatMessage {\n role: \"user\".to_string(),\n content: \"Return JSON.\".to_string(),\n }],\n model: \"deepseek-v4-flash\".to_string(),\n temperature: 0.0,\n max_tokens: 8192,\n force_json: true,\n provider_options: AIProviderOptions::default(),\n })\n .expect(\"build DeepSeek body\");\n let (body, _, _) = provider\n .build_chat_body(&AIChatRequest {\n messages: vec![AIChatMessage {\n role: \"user\".to_string(),\n content: \"Return JSON.\".to_string(),\n }],\n model: \"deepseek-v4-flash\".to_string(),\n temperature: 0.0,\n max_tokens: 8192,\n force_json: true,\n provider_options: AIProviderOptions::default(),\n })\n .expect(\"build DeepSeek body\");\n assert!(used_response_format);\n assert_eq!(body[\"response_format\"][\"type\"], \"json_object\");\n assert_eq!(thinking.as_deref(), Some(\"disabled\"));\n }\n\n #[test]\n fn json_only_retry_keeps_response_format_and_disables_thinking() {\n let settings = AISettings {\n enable_thinking: true,\n ..AISettings::default()\n };\n let provider = OpenAICompatibleProvider::new(settings);\n let (body, used_response_format, thinking) = provider\n .build_chat_body(&AIChatRequest {\n messages: Vec::new(),\n model: \"deepseek-v4-flash\".to_string(),\n temperature: 0.0,\n max_tokens: 512,\n force_json: true,\n provider_options: AIProviderOptions {\n enable_thinking: Some(false),\n use_response_format: Some(true),\n ..Default::default()\n },\n })\n .expect(\"build retry body\");\n assert!(used_response_format);\n assert_eq!(body[\"response_format\"][\"type\"], \"json_object\");\n assert_eq!(body[\"thinking\"][\"type\"], \"disabled\");\n assert_eq!(thinking.as_deref(), Some(\"disabled\"));\n }\n\n #[test]\n fn registry_profiles_select_provider_specific_payload_fields() {\n let minimax_settings = AISettings {\n preset: AIProviderPresetId::Minimax,\n base_url: \"https://api.minimaxi.com/v1\".to_string(),\n enable_thinking: true,\n ..AISettings::default()\n };\n let minimax = OpenAICompatibleProvider::new(minimax_settings);\n let (body, _, _) = minimax\n .build_chat_body(&AIChatRequest {\n messages: Vec::new(),\n model: \"MiniMax-M2.5\".to_string(),\n temperature: 1.5,\n max_tokens: 100,\n force_json: true,\n provider_options: AIProviderOptions::default(),\n })\n .expect(\"MiniMax body\");\n assert_eq!(body[\"max_completion_tokens\"], 100);\n assert_eq!(body[\"reasoning_split\"], true);\n assert_eq!(body[\"temperature\"], 1.0);\n\n let qwen_settings = AISettings {\n preset: AIProviderPresetId::QwenDashScope,\n base_url: \"https://dashscope.aliyuncs.com/compatible-mode/v1\".to_string(),\n enable_thinking: true,\n ..AISettings::default()\n };\n let qwen = OpenAICompatibleProvider::new(qwen_settings);\n let (body, _, _) = qwen\n .build_chat_body(&AIChatRequest {\n messages: Vec::new(),\n model: \"qwen-plus\".to_string(),\n temperature: 0.2,\n max_tokens: 100,\n force_json: true,\n provider_options: AIProviderOptions::default(),\n })\n .expect(\"Qwen body\");\n assert_eq!(body[\"response_format\"][\"type\"], \"json_object\");\n assert_eq!(body[\"enable_thinking\"], true);\n }\n\n #[test]\n fn finish_reason_length_is_reported_as_truncated_json() {\n let error = parse_openai_content(\n r#\"{\"choices\":[{\"finish_reason\":\"length\",\"message\":{\"content\":\"{\\\"classifications\\\":[\"}}]}\"#,\n )\n .expect_err(\"length-limited output must fail\");\n assert!(error.contains(\"模型输出达到长度上限,JSON 被截断。\"));\n assert!(error.contains(\"finish_reason=length\"));\n }\n\n #[test]\n fn empty_json_content_is_explicitly_retryable() {\n let error = parse_openai_content(\n r#\"{\"choices\":[{\"finish_reason\":\"stop\",\"message\":{\"content\":\"\"}}]}\"#,\n )\n .expect_err(\"empty content must fail\");\n assert!(error.contains(\"AI provider returned empty message.content.\"));\n assert!(error.contains(\"JSON content is empty and should be retried\"));\n }\n\n #[test]\n fn parse_openai_content_includes_short_non_json_preview() {\n let text = r#\"{\"choices\":[{\"finish_reason\":\"stop\",\"message\":{\"content\":\"Sure, I can classify that file.\"}}]}\"#;\n let error = parse_openai_content(text).expect_err(\"non-json content should fail\");\n assert!(error.contains(\"AI provider returned non-JSON message content.\"));\n assert!(error.contains(\"content_preview=\\\"Sure, I can classify that file.\\\"\"));\n assert!(error.contains(\"message_keys=[content]\"));\n }\n\n #[test]\n fn provider_error_redaction_removes_api_key_from_summary() {\n let api_key = \"sk-provider-secret\";\n let text = r#\"{\"choices\":[{\"finish_reason\":\"stop\",\"message\":{\"content\":\"Provider rejected sk-provider-secret\"}}]}\"#;\n let error = parse_openai_content(text).expect_err(\"non-json content should fail\");\n let redacted = redact_api_key(&error, api_key);\n assert!(!redacted.contains(api_key));\n assert!(redacted.contains(\"[redacted]\"));\n }\n\n #[test]\n fn provider_rejects_redirects_without_forwarding_credentials() {\n let listener = TcpListener::bind(\"127.0.0.1:0\").expect(\"bind redirect fixture\");\n let address = listener.local_addr().expect(\"redirect address\");\n let server = thread::spawn(move || {\n let (mut stream, _) = listener.accept().expect(\"accept redirect request\");\n let mut request = [0_u8; 4096];\n let size = stream.read(&mut request).expect(\"read redirect request\");\n let request = String::from_utf8_lossy(&request[..size]);\n assert!(request\n .to_ascii_lowercase()\n .contains(\"authorization: bearer sk-provider-secret\"));\n stream\n .write_all(\n b\"HTTP/1.1 307 Temporary Redirect\\r\\nLocation: http://127.0.0.1:9/second\\r\\nContent-Length: 0\\r\\n\\r\\n\",\n )\n .expect(\"write redirect response\");\n });\n\n let settings = AISettings {\n base_url: format!(\"http://{address}\"),\n api_key: \"sk-provider-secret\".to_string(),\n ..AISettings::default()\n };\n let provider = OpenAICompatibleProvider::new(settings);\n let error = provider\n .send_chat_request_raw(AIChatRequest {\n messages: vec![AIChatMessage {\n role: \"user\".to_string(),\n content: \"hello\".to_string(),\n }],\n model: \"test-model\".to_string(),\n temperature: 0.0,\n max_tokens: 16,\n force_json: false,\n provider_options: AIProviderOptions::default(),\n })\n .expect_err(\"redirect must be rejected\");\n server.join().expect(\"redirect server\");\n let message = error.to_string();\n assert!(message.contains(\"redirect rejected\"));\n assert!(!message.contains(\"sk-provider-secret\"));\n }\n}\n"}, "6611e94dd67f5f17514984ea33aadb58d75e7256": {"t": 1785757164.900435, "n": 53571, "h": "8d196fc44ad402aa", "c": "use std::time::{Duration, Instant};\n\nuse reqwest::blocking::Client;\nuse serde_json::{json, Map, Value};\n\nuse super::{\n presets::{provider_preset, AIProviderPreset},\n prompts::{clean_ai_json_text, extract_first_json_value},\n provider::{AIProvider, AIProviderError},\n registry::{AIAuthKind, AIThinkingStrategy, AITokenParameter},\n schema::{\n AIChatMessage, AIChatRequest, AIConnectionTestResult, AIModelInfo, AIProviderOptions,\n AIProviderPresetId,\n },\n settings::AISettings,\n trace::{\n now_iso, record_trace, update_trace_with_secrets, AIRequestTrace, AITraceContext,\n AITraceMode, AITraceOperation, AITraceRequest, AITraceResponse, AITraceUpdate,\n AITraceUsage, SecretRedactor,\n },\n};\n\ntype ChatBody = (Map<String, Value>, bool, Option<String>);\n\npub struct OpenAICompatibleProvider {\n settings: AISettings,\n preset: AIProviderPreset,\n client: Option<Client>,\n}\n\n#[derive(Debug, Clone)]\npub struct AIRawProviderResponse {\n pub status: u16,\n pub response_text: String,\n pub request_used_response_format: bool,\n pub request_used_thinking_field: Option<String>,\n pub response_summary: String,\n pub trace_id: Option<String>,\n pub(crate) pending_trace: Option<AIRequestTrace>,\n}\n\n#[derive(Debug, Clone, serde::Serialize)]\npub struct AIDebugExtractResult {\n pub finish_reason: Option<String>,\n pub message_keys: Vec<String>,\n pub message_content: Option<String>,\n pub reasoning_content: Option<String>,\n pub output_text: Option<String>,\n pub text: Option<String>,\n pub extracted_content: Option<String>,\n pub parse_error: Option<String>,\n}\n\nimpl OpenAICompatibleProvider {\n pub fn new(settings: AISettings) -> Self {\n let mut preset = provider_preset(settings.preset)\n .or_else(|| provider_preset(AIProviderPresetId::CustomOpenAICompatible))\n .expect(\"custom OpenAI-compatible preset exists\");\n if let Some(profile) = settings\n .active_custom_profile_id\n .as_deref()\n .and_then(|profile_id| {\n settings\n .custom_profiles\n .iter()\n .find(|profile| profile.id == profile_id)\n })\n {\n preset.capabilities.supports_response_format_json_object =\n profile.supports_response_format;\n preset.capabilities.supports_thinking = profile.supports_thinking;\n preset.capabilities.supports_thinking_toggle = profile.supports_thinking;\n preset.capabilities.supports_reasoning_effort = profile\n .thinking_parameter\n .eq_ignore_ascii_case(\"reasoning_effort\");\n preset.supports_response_format = profile.supports_response_format;\n preset.supports_json_mode = profile.supports_response_format;\n preset.supports_thinking = profile.supports_thinking;\n preset.supports_reasoning_effort = profile\n .thinking_parameter\n .eq_ignore_ascii_case(\"reasoning_effort\");\n preset.parameter_profile.token_parameter = if profile\n .token_parameter\n .eq_ignore_ascii_case(\"max_completion_tokens\")\n {\n AITokenParameter::MaxCompletionTokens\n } else {\n AITokenParameter::MaxTokens\n };\n preset.parameter_profile.thinking_strategy =\n match profile.thinking_parameter.to_ascii_lowercase().as_str() {\n \"none\" => AIThinkingStrategy::None,\n \"enable_thinking\" | \"boolean\" => AIThinkingStrategy::EnableThinkingBoolean,\n \"reasoning_effort\" => AIThinkingStrategy::ReasoningEffort,\n \"minimax_reasoning_split\" | \"reasoning_split\" => {\n AIThinkingStrategy::MiniMaxReasoningSplit\n }\n \"prompt_only\" => AIThinkingStrategy::PromptOnly,\n _ => AIThinkingStrategy::GenericThinkingObject,\n };\n }\n let client = Client::builder()\n .timeout(Duration::from_secs(settings.timeout_seconds.max(1)))\n .redirect(reqwest::redirect::Policy::none())\n .build()\n .ok();\n Self {\n settings,\n preset,\n client,\n }\n }\n\n fn request_url(&self) -> Result<String, AIProviderError> {\n join_base_url_and_chat_path(&self.settings.base_url, &self.settings.chat_path)\n }\n\n fn client(&self) -> Result<&Client, AIProviderError> {\n self.client\n .as_ref()\n .ok_or_else(|| self.error(\"failed to build AI HTTP client\"))\n }\n\n fn authorize_request(\n &self,\n builder: reqwest::blocking::RequestBuilder,\n ) -> reqwest::blocking::RequestBuilder {\n let api_key = self.settings.api_key.trim();\n if api_key.is_empty() {\n return builder;\n }\n match self.preset.auth_kind {\n AIAuthKind::BearerApiKey | AIAuthKind::QianfanAkSk => builder.bearer_auth(api_key),\n AIAuthKind::ApiKeyHeader => builder.header(\"X-API-Key\", api_key),\n AIAuthKind::None => builder,\n }\n }\n\n fn trace_secrets(&self, request: &AIChatRequest) -> Vec<String> {\n let mut secrets = vec![self.settings.api_key.clone()];\n if let Some(context) = request.provider_options.trace_context.as_ref() {\n secrets.extend(context.redaction_secrets.iter().cloned());\n }\n secrets\n }\n\n fn error(&self, message: impl Into<String>) -> AIProviderError {\n AIProviderError::new(redact_api_key(&message.into(), &self.settings.api_key))\n }\n\n pub fn send_chat_request_raw(\n &self,\n request: AIChatRequest,\n ) -> Result<AIRawProviderResponse, AIProviderError> {\n let started = Instant::now();\n let url = self.request_url()?;\n let (body, request_used_response_format, request_used_thinking_field) =\n self.build_chat_body(&request)?;\n let trace_mode = self.settings.diagnostics_mode;\n let mut trace = self.build_trace(\n &request,\n &body,\n &url,\n request_used_response_format,\n request_used_thinking_field.clone(),\n );\n\n let mut builder = self\n .client()?\n .post(&url)\n .header(reqwest::header::CONTENT_TYPE, \"application/json\");\n builder = self.authorize_request(builder);\n\n let response = match builder.json(&Value::Object(body)).send() {\n Ok(response) => response,\n Err(error) => {\n let message = self.error(format!(\"AI request failed: {error}\"));\n trace.elapsed_ms = started.elapsed().as_millis();\n trace.parse_stage = \"transport_error\".to_string();\n trace.error_code = Some(\"transport_error\".to_string());\n trace.error_message = Some(message.to_string());\n record_trace(trace_mode, trace, true);\n return Err(message);\n }\n };\n let status = response.status();\n if status.is_redirection() {\n let message = self.error(format!(\n \"AI provider redirect rejected: HTTP {}\",\n status.as_u16()\n ));\n trace.elapsed_ms = started.elapsed().as_millis();\n trace.response.http_status = Some(status.as_u16());\n trace.parse_stage = \"transport_error\".to_string();\n trace.error_code = Some(\"redirect_rejected\".to_string());\n trace.error_message = Some(message.to_string());\n record_trace(trace_mode, trace, true);\n return Err(message);\n }\n let response_text = response\n .text()\n .map_err(|error| self.error(format!(\"failed to read AI response: {error}\")))?;\n let trace_secrets = self.trace_secrets(&request);\n let redactor = SecretRedactor::new(trace_secrets.iter().map(String::as_str));\n let (redacted_response, raw_truncated) = redactor.redact_optional_text(\n Some(&response_text),\n super::trace::MAX_RAW_PROVIDER_RESPONSE_CHARS,\n );\n let redacted_response = redacted_response.unwrap_or_default();\n let response_summary = serde_json::from_str::<Value>(&response_text)\n .map(|value| summarize_provider_response(&value))\n .unwrap_or_else(|error| format!(\"provider response summary: invalid_json={error}\"));\n\n trace.elapsed_ms = started.elapsed().as_millis();\n trace.response = response_trace_metadata(status.as_u16(), &response_text);\n trace.raw_provider_response = Some(redacted_response.clone());\n trace.truncated |= raw_truncated;\n trace.parse_stage = if status.is_success() {\n \"provider_response\".to_string()\n } else {\n \"http_error\".to_string()\n };\n if !status.is_success() {\n trace.error_code = Some(format!(\"http_{}\", status.as_u16()));\n trace.error_message = Some(format!(\n \"AI provider returned HTTP {}: {}\",\n status.as_u16(),\n short_response(&redacted_response)\n ));\n }\n let trace_id = record_trace(trace_mode, trace.clone(), !status.is_success());\n let pending_trace = if trace_id.is_none() && !matches!(trace_mode, AITraceMode::Off) {\n Some(trace)\n } else {\n None\n };\n\n Ok(AIRawProviderResponse {\n status: status.as_u16(),\n response_text: redacted_response,\n request_used_response_format,\n request_used_thinking_field,\n response_summary,\n trace_id,\n pending_trace,\n })\n }\n\n fn build_trace(\n &self,\n request: &AIChatRequest,\n body: &Map<String, Value>,\n url: &str,\n request_used_response_format: bool,\n request_used_thinking_field: Option<String>,\n ) -> AIRequestTrace {\n let parsed_url = url::Url::parse(url).ok();\n let mut extra_body_keys = body\n .keys()\n .filter(|key| {\n !matches!(\n key.as_str(),\n \"model\"\n | \"messages\"\n | \"stream\"\n | \"temperature\"\n | \"max_tokens\"\n | \"max_completion_tokens\"\n | \"response_format\"\n | \"thinking\"\n | \"enable_thinking\"\n | \"reasoning_split\"\n | \"reasoning_effort\"\n )\n })\n .cloned()\n .collect::<Vec<_>>();\n extra_body_keys.sort();\n let context = request\n .provider_options\n .trace_context\n .clone()\n .unwrap_or_default();\n AIRequestTrace {\n trace_id: String::new(),\n job_id: context.job_id.clone(),\n batch_id: context.batch_id.clone(),\n started_at: now_iso(),\n elapsed_ms: 0,\n operation: context.operation,\n provider_id: format!(\"{:?}\", self.preset.id),\n provider_label: self.preset.label.to_string(),\n model: request.model.clone(),\n request: AITraceRequest {\n url_host: parsed_url\n .as_ref()\n .and_then(url::Url::host_str)\n .unwrap_or_default()\n .to_string(),\n path: parsed_url\n .as_ref()\n .map(|url| url.path().to_string())\n .unwrap_or_else(|| \"/\".to_string()),\n message_count: request.messages.len(),\n target_count: context.target_count,\n batch_size: context.batch_size,\n max_tokens: Some(request.max_tokens),\n temperature: body\n .get(\"temperature\")\n .and_then(Value::as_f64)\n .map(|temperature| temperature as f32),\n force_json: request.force_json,\n response_format: request_used_response_format.then(|| \"json_object\".to_string()),\n thinking_mode: request_used_thinking_field,\n extra_body_keys,\n },\n parse_stage: \"request_sent\".to_string(),\n ..AIRequestTrace::default()\n }\n }\n\n fn build_chat_body(&self, request: &AIChatRequest) -> Result<ChatBody, AIProviderError> {\n let mut body = Map::new();\n body.insert(\"model\".to_string(), json!(request.model));\n body.insert(\n \"messages\".to_string(),\n json!(messages_with_instructions(\n &request.messages,\n request.force_json,\n thinking_enabled(&self.settings, request),\n self.preset.id\n )),\n );\n let temperature = request.temperature.clamp(\n self.preset.parameter_profile.temperature_min as f32,\n self.preset.parameter_profile.temperature_max as f32,\n );\n body.insert(\"temperature\".to_string(), json!(temperature));\n let token_field = match self.preset.parameter_profile.token_parameter {\n AITokenParameter::MaxTokens => \"max_tokens\",\n AITokenParameter::MaxCompletionTokens => \"max_completion_tokens\",\n };\n body.insert(token_field.to_string(), json!(request.max_tokens));\n\n let response_format_enabled = request.provider_options.use_response_format.unwrap_or(\n self.preset\n .capabilities\n .supports_response_format_json_object,\n );\n if request.force_json && response_format_enabled {\n body.insert(\n \"response_format\".to_string(),\n json!({ \"type\": \"json_object\" }),\n );\n }\n\n // Advanced fields may extend the request, but core request semantics stay authoritative.\n merge_extra_body(\n &mut body,\n self.settings.extra_body_json.as_deref(),\n &self.settings.api_key,\n )\n .map_err(|error| self.error(error.to_string()))?;\n merge_extra_body(\n &mut body,\n request.provider_options.extra_body_json.as_deref(),\n &self.settings.api_key,\n )\n .map_err(|error| self.error(error.to_string()))?;\n\n let enable_thinking = thinking_enabled(&self.settings, request);\n if enable_thinking && self.preset.supports_reasoning_effort {\n if let Some(reasoning_effort) = request\n .provider_options\n .reasoning_effort\n .as_deref()\n .or(self.settings.reasoning_effort.as_deref())\n .filter(|value| !value.trim().is_empty())\n {\n body.insert(\"reasoning_effort\".to_string(), json!(reasoning_effort));\n }\n }\n match self.preset.parameter_profile.thinking_strategy {\n AIThinkingStrategy::DeepSeekThinkingObject\n if self.preset.capabilities.supports_thinking_toggle =>\n {\n body.insert(\n \"thinking\".to_string(),\n json!({ \"type\": if enable_thinking { \"enabled\" } else { \"disabled\" } }),\n );\n }\n AIThinkingStrategy::GenericThinkingObject\n if self.preset.capabilities.supports_thinking_toggle =>\n {\n body.insert(\n \"thinking\".to_string(),\n json!({ \"type\": if enable_thinking { \"enabled\" } else { \"disabled\" } }),\n );\n }\n AIThinkingStrategy::EnableThinkingBoolean\n if self.preset.capabilities.supports_thinking_toggle =>\n {\n body.insert(\"enable_thinking\".to_string(), json!(enable_thinking));\n }\n AIThinkingStrategy::MiniMaxReasoningSplit => {\n body.insert(\"reasoning_split\".to_string(), json!(enable_thinking));\n }\n _ => {}\n }\n\n let request_used_thinking_field = body.get(\"thinking\").map(thinking_field_text);\n let request_used_thinking_field = request_used_thinking_field.or_else(|| {\n body.get(\"enable_thinking\").map(|value| {\n value\n .as_bool()\n .map(|enabled| enabled.to_string())\n .unwrap_or_else(|| value.to_string())\n })\n });\n Ok((\n body,\n request.force_json && response_format_enabled,\n request_used_thinking_field,\n ))\n }\n}\n\nimpl AIProvider for OpenAICompatibleProvider {\n fn chat_json(&self, request: AIChatRequest) -> Result<String, AIProviderError> {\n let raw = self.send_chat_request_raw(request.clone())?;\n if !(200..300).contains(&raw.status) {\n let mut message = format!(\n \"AI provider returned HTTP {}: {}\",\n raw.status,\n short_response(&raw.response_text)\n );\n if self.preset.id == AIProviderPresetId::DeepSeek\n && !thinking_enabled(&self.settings, &request)\n {\n message.push_str(\n \" If DeepSeek rejects thinking disabled, use a non-thinking model or a compatible legacy model name.\",\n );\n }\n let error = self.error(message);\n if let Some(trace_id) = raw.trace_id.as_deref() {\n let trace_secrets = self.trace_secrets(&request);\n update_trace_with_secrets(\n trace_id,\n AITraceUpdate {\n parse_stage: Some(\"http_error\".to_string()),\n error_code: Some(format!(\"http_{}\", raw.status)),\n error_message: Some(error.to_string()),\n ..AITraceUpdate::default()\n },\n trace_secrets.iter().map(String::as_str),\n );\n }\n return Err(error);\n }\n\n match parse_openai_content(&raw.response_text) {\n Ok(content) => {\n let cleaned_json_text = clean_ai_json_text(&content);\n let parsed_json = serde_json::from_str::<Value>(&cleaned_json_text)\n .ok()\n .or_else(|| {\n extract_first_json_value(&content)\n .and_then(|value| serde_json::from_str::<Value>(&value).ok())\n });\n if let Some(trace_id) = raw.trace_id.as_deref() {\n let trace_secrets = self.trace_secrets(&request);\n update_trace_with_secrets(\n trace_id,\n AITraceUpdate {\n extracted_content: Some(content.clone()),\n cleaned_json_text: Some(cleaned_json_text),\n parsed_json,\n parse_stage: Some(\"extracted_content\".to_string()),\n ..AITraceUpdate::default()\n },\n trace_secrets.iter().map(String::as_str),\n );\n }\n Ok(content)\n }\n Err(error) => {\n let error = self.error(error);\n let trace_secrets = self.trace_secrets(&request);\n if let Some(trace_id) = raw.trace_id.as_deref() {\n update_trace_with_secrets(\n trace_id,\n AITraceUpdate {\n parse_stage: Some(\"parse_error\".to_string()),\n error_code: Some(\"response_parse_error\".to_string()),\n error_message: Some(error.to_string()),\n ..AITraceUpdate::default()\n },\n trace_secrets.iter().map(String::as_str),\n );\n } else if let Some(mut trace) = raw.pending_trace {\n trace.parse_stage = \"parse_error\".to_string();\n trace.error_code = Some(\"response_parse_error\".to_string());\n trace.error_message = Some(\n SecretRedactor::new(trace_secrets.iter().map(String::as_str))\n .redact_text(&error.to_string()),\n );\n record_trace(self.settings.diagnostics_mode, trace, true);\n }\n Err(error)\n }\n }\n }\n\n fn test_connection(&self) -> Result<AIConnectionTestResult, AIProviderError> {\n let mut prompt = \"Return exactly this JSON and nothing else: {\\\"ok\\\":true}\\nDo not output Markdown.\\nDo not output reasoning.\\nDo not output <think>.\\nDo not explain.\".to_string();\n if self.preset.id == AIProviderPresetId::DeepSeek && !self.settings.enable_thinking {\n prompt.push_str(\"\\nUse non-thinking mode and only return final content.\");\n }\n let test_max_tokens = self.settings.max_tokens.clamp(512, 4096);\n let content = self.chat_json(AIChatRequest {\n messages: vec![AIChatMessage {\n role: \"user\".to_string(),\n content: prompt,\n }],\n model: self.settings.model.clone(),\n temperature: 0.0,\n max_tokens: test_max_tokens,\n force_json: true,\n provider_options: AIProviderOptions {\n trace_context: Some(AITraceContext {\n operation: AITraceOperation::ConnectionTest,\n ..Default::default()\n }),\n ..Default::default()\n },\n })?;\n Ok(AIConnectionTestResult {\n ok: true,\n message: format!(\"AI provider responded: {}\", short_response(&content)),\n model: Some(self.settings.model.clone()),\n provider: Some(self.settings.provider),\n preset: Some(self.settings.preset),\n elapsed_ms: 0,\n })\n }\n\n fn discover_models(&self) -> Result<Vec<AIModelInfo>, AIProviderError> {\n let models_path = self\n .settings\n .models_path\n .as_deref()\n .or(self.preset.models_path)\n .filter(|path| !path.trim().is_empty())\n .ok_or_else(|| {\n self.error(\"This provider does not expose an OpenAI-compatible models endpoint.\")\n })?;\n let url = join_base_url_and_chat_path(&self.settings.base_url, models_path)?;\n let mut builder = self\n .client()?\n .get(url)\n .header(reqwest::header::ACCEPT, \"application/json\");\n builder = self.authorize_request(builder);\n let response = builder\n .send()\n .map_err(|error| self.error(format!(\"AI model discovery request failed: {error}\")))?;\n let status = response.status();\n let text = response.text().map_err(|error| {\n self.error(format!(\n \"failed to read AI model discovery response: {error}\"\n ))\n })?;\n if !status.is_success() {\n return Err(self.error(format!(\n \"AI model discovery returned HTTP {}: {}\",\n status.as_u16(),\n short_response(&redact_api_key(&text, &self.settings.api_key))\n )));\n }\n parse_model_list(&text).map_err(|error| self.error(error))\n }\n}\n\npub fn join_base_url_and_chat_path(\n base_url: &str,\n chat_path: &str,\n) -> Result<String, AIProviderError> {\n let base = base_url.trim().trim_end_matches('/');\n let path = chat_path.trim().trim_start_matches('/');\n if base.is_empty() {\n return Err(AIProviderError::new(\"AI provider base_url is empty.\"));\n }\n if path.is_empty() {\n return Err(AIProviderError::new(\"AI provider chat_path is empty.\"));\n }\n Ok(format!(\"{base}/{path}\"))\n}\n\nfn messages_with_instructions(\n messages: &[AIChatMessage],\n force_json: bool,\n enable_thinking: bool,\n preset_id: AIProviderPresetId,\n) -> Vec<AIChatMessage> {\n let mut output = Vec::new();\n let mut instructions = Vec::new();\n if force_json {\n instructions.push(\"Return JSON only; the final content must be one complete valid JSON object. Include the word JSON. Do not wrap JSON in markdown or code fences.\".to_string());\n }\n if !enable_thinking {\n instructions.push(\"Do not output thinking, reasoning traces, or explanations; return the final JSON content directly.\".to_string());\n }\n if preset_id == AIProviderPresetId::DeepSeek {\n instructions.push(\n \"DeepSeek legacy model names deepseek-chat and deepseek-reasoner may be deprecated; keep output compatible with the selected model.\".to_string(),\n );\n }\n if !instructions.is_empty() {\n output.push(AIChatMessage {\n role: \"system\".to_string(),\n content: instructions.join(\" \"),\n });\n }\n output.extend_from_slice(messages);\n output\n}\n\nfn thinking_enabled(settings: &AISettings, request: &AIChatRequest) -> bool {\n request\n .provider_options\n .enable_thinking\n .unwrap_or(settings.enable_thinking)\n}\n\nfn merge_extra_body(\n body: &mut Map<String, Value>,\n extra_body_json: Option<&str>,\n api_key: &str,\n) -> Result<(), AIProviderError> {\n let Some(extra_body_json) = extra_body_json.filter(|value| !value.trim().is_empty()) else {\n return Ok(());\n };\n let value = serde_json::from_str::<Value>(extra_body_json).map_err(|error| {\n AIProviderError::new(redact_api_key(\n &format!(\"AI extra_body_json must be a JSON object: {error}\"),\n api_key,\n ))\n })?;\n let Value::Object(extra) = value else {\n return Err(AIProviderError::new(\n \"AI extra_body_json must be a JSON object.\",\n ));\n };\n for (key, value) in extra {\n if matches!(\n key.as_str(),\n \"model\"\n | \"messages\"\n | \"stream\"\n | \"temperature\"\n | \"max_tokens\"\n | \"max_completion_tokens\"\n | \"response_format\"\n | \"thinking\"\n | \"reasoning_effort\"\n | \"enable_thinking\"\n | \"reasoning_split\"\n | \"reasoning\"\n ) {\n continue;\n }\n body.insert(key, value);\n }\n Ok(())\n}\n\nfn parse_openai_content(text: &str) -> Result<String, String> {\n let value = serde_json::from_str::<Value>(text)\n .map_err(|error| format!(\"AI provider returned invalid JSON: {error}\"))?;\n let summary = summarize_provider_response(&value);\n let finish_reason = response_finish_reason(&value);\n if matches!(finish_reason.as_deref(), Some(\"length\" | \"max_tokens\")) {\n return Err(format!(\n \"模型输出达到长度上限,JSON 被截断。AI provider finish_reason={}。请提高 max_tokens 或降低 batch size。 {summary}\",\n finish_reason.as_deref().unwrap_or(\"length\")\n ));\n }\n if matches!(finish_reason.as_deref(), Some(\"content_filter\" | \"safety\")) {\n return Err(format!(\n \"AI provider blocked the response because of content filtering (finish_reason={}). {summary}\",\n finish_reason.as_deref().unwrap_or(\"content_filter\")\n ));\n }\n\n let reasoning = response_reasoning_text(&value);\n let content = response_content_text(&value);\n match content {\n Some(content) if !content.trim().is_empty() => {\n validate_content_has_jsonish_text(strip_think_tags_if_needed(content), &summary)\n }\n Some(_) if !reasoning.trim().is_empty() => Err(format!(\n \"AI provider returned reasoning_content but empty content. Please disable Thinking or use a non-thinking model. {summary}\"\n )),\n Some(_) => Err(format!(\n \"AI provider returned empty message.content. JSON content is empty and should be retried. {summary}\"\n )),\n None if !reasoning.trim().is_empty() => Err(format!(\n \"AI provider returned reasoning_content but no final content. Please disable Thinking or use a non-thinking model. {summary}\"\n )),\n None => Err(format!(\n \"AI provider response did not contain message.content, message.output_text, or message.text. {summary}\"\n )),\n }\n}\n\nfn response_finish_reason(value: &Value) -> Option<String> {\n value\n .get(\"choices\")\n .and_then(Value::as_array)\n .and_then(|choices| choices.first())\n .and_then(|choice| choice.get(\"finish_reason\"))\n .or_else(|| value.get(\"finish_reason\"))\n .and_then(Value::as_str)\n .map(ToString::to_string)\n}\n\nfn response_content_text(value: &Value) -> Option<String> {\n let choice = value\n .get(\"choices\")\n .and_then(Value::as_array)\n .and_then(|choices| choices.first());\n let message = choice.and_then(|choice| choice.get(\"message\"));\n for candidate in [\n message.and_then(|message| message.get(\"content\")),\n message.and_then(|message| message.get(\"output_text\")),\n message.and_then(|message| message.get(\"text\")),\n choice.and_then(|choice| choice.get(\"text\")),\n value.get(\"output_text\"),\n value.get(\"content\"),\n value.get(\"output\").and_then(|output| output.get(\"text\")),\n value.get(\"output\").and_then(|output| output.get(\"content\")),\n ]\n .into_iter()\n .flatten()\n {\n if let Some(text) = content_value_to_text(candidate) {\n return Some(text);\n }\n }\n None\n}\n\nfn response_reasoning_text(value: &Value) -> String {\n let choice = value\n .get(\"choices\")\n .and_then(Value::as_array)\n .and_then(|choices| choices.first());\n let message = choice.and_then(|choice| choice.get(\"message\"));\n [\n message.and_then(|message| message.get(\"reasoning_content\")),\n message.and_then(|message| message.get(\"reasoning_details\")),\n choice.and_then(|choice| choice.get(\"reasoning_content\")),\n value.get(\"reasoning_content\"),\n value.get(\"reasoning_details\"),\n ]\n .into_iter()\n .flatten()\n .filter_map(content_value_to_text)\n .collect::<Vec<_>>()\n .join(\"\\n\")\n}\n\nfn response_trace_metadata(status: u16, text: &str) -> AITraceResponse {\n let Ok(value) = serde_json::from_str::<Value>(text) else {\n return AITraceResponse {\n http_status: Some(status),\n ..AITraceResponse::default()\n };\n };\n let choice = value\n .get(\"choices\")\n .and_then(Value::as_array)\n .and_then(|choices| choices.first());\n let message = choice\n .and_then(|choice| choice.get(\"message\"))\n .and_then(Value::as_object);\n let mut message_keys = message\n .map(|message| message.keys().cloned().collect::<Vec<_>>())\n .unwrap_or_default();\n message_keys.sort();\n let content_value = message\n .and_then(|message| message.get(\"content\"))\n .or_else(|| value.get(\"content\"));\n let reasoning_value = message\n .and_then(|message| message.get(\"reasoning_content\"))\n .or_else(|| message.and_then(|message| message.get(\"reasoning_details\")))\n .or_else(|| value.get(\"reasoning_content\"));\n let usage = value.get(\"usage\").map(|usage| AITraceUsage {\n prompt_tokens: usage_number(usage, &[\"prompt_tokens\", \"input_tokens\"]),\n completion_tokens: usage_number(usage, &[\"completion_tokens\", \"output_tokens\"]),\n total_tokens: usage_number(usage, &[\"total_tokens\"]),\n });\n AITraceResponse {\n http_status: Some(status),\n finish_reason: response_finish_reason(&value),\n message_keys,\n content_type: content_value.map(json_type_name).map(ToString::to_string),\n content_length: Some(value_text_len(content_value)),\n reasoning_content_length: Some(value_text_len(reasoning_value)),\n usage,\n }\n}\n\nfn usage_number(value: &Value, keys: &[&str]) -> Option<u64> {\n keys.iter()\n .find_map(|key| value.get(*key).and_then(Value::as_u64))\n}\n\nfn strip_think_tags_if_needed(content: String) -> String {\n let mut output = content;\n while let Some(start) = output.to_ascii_lowercase().find(\"<think>\") {\n let Some(relative_end) = output[start..].to_ascii_lowercase().find(\"</think>\") else {\n break;\n };\n let end = start + relative_end + \"</think>\".len();\n output.replace_range(start..end, \"\");\n }\n output.trim().to_string()\n}\n\npub fn debug_extract_openai_response(raw_response: &str) -> AIDebugExtractResult {\n let value = match serde_json::from_str::<Value>(raw_response) {\n Ok(value) => value,\n Err(error) => {\n return AIDebugExtractResult {\n finish_reason: None,\n message_keys: Vec::new(),\n message_content: None,\n reasoning_content: None,\n output_text: None,\n text: None,\n extracted_content: None,\n parse_error: Some(format!(\"provider raw response is not JSON: {error}\")),\n };\n }\n };\n let choice = value\n .get(\"choices\")\n .and_then(Value::as_array)\n .and_then(|choices| choices.first());\n let message = choice\n .and_then(|choice| choice.get(\"message\"))\n .and_then(Value::as_object);\n let mut message_keys = message\n .map(|message| message.keys().cloned().collect::<Vec<_>>())\n .unwrap_or_default();\n message_keys.sort();\n let message_content = message\n .and_then(|message| message.get(\"content\"))\n .and_then(content_value_to_text);\n let output_text = message\n .and_then(|message| message.get(\"output_text\"))\n .and_then(content_value_to_text);\n let text = message\n .and_then(|message| message.get(\"text\"))\n .and_then(content_value_to_text);\n let extracted_content = message_content\n .clone()\n .or_else(|| output_text.clone())\n .or_else(|| text.clone());\n\n AIDebugExtractResult {\n finish_reason: choice\n .and_then(|choice| choice.get(\"finish_reason\"))\n .and_then(Value::as_str)\n .map(ToString::to_string),\n message_keys,\n message_content,\n reasoning_content: message\n .and_then(|message| message.get(\"reasoning_content\"))\n .and_then(content_value_to_text),\n output_text,\n text,\n extracted_content,\n parse_error: message\n .is_none()\n .then(|| \"provider response did not contain choices[0].message\".to_string()),\n }\n}\n\npub(crate) fn summarize_provider_response(value: &Value) -> String {\n let choices = value.get(\"choices\").and_then(Value::as_array);\n let choice = choices.and_then(|items| items.first());\n let message = choice\n .and_then(|choice| choice.get(\"message\"))\n .and_then(Value::as_object);\n let mut message_keys = message\n .map(|message| message.keys().cloned().collect::<Vec<_>>())\n .unwrap_or_default();\n message_keys.sort();\n let content = message.and_then(|message| message.get(\"content\"));\n let reasoning_content = message.and_then(|message| message.get(\"reasoning_content\"));\n format!(\n \"provider response summary: has_choices={}; choice_count={}; finish_reason={}; message_keys={}; content_type={}; content_length={}; has_reasoning_content={}; reasoning_content_length={}\",\n choices.is_some(),\n choices.map_or(0, Vec::len),\n choice\n .and_then(|choice| choice.get(\"finish_reason\"))\n .map(json_type_or_string)\n .unwrap_or_else(|| \"missing\".to_string()),\n if message_keys.is_empty() {\n \"[]\".to_string()\n } else {\n format!(\"[{}]\", message_keys.join(\",\"))\n },\n content.map(json_type_name).unwrap_or(\"missing\"),\n value_text_len(content),\n reasoning_content.is_some(),\n value_text_len(reasoning_content),\n )\n}\n\nfn thinking_field_text(value: &Value) -> String {\n value\n .get(\"type\")\n .and_then(Value::as_str)\n .map(ToString::to_string)\n .unwrap_or_else(|| {\n serde_json::to_string(value).unwrap_or_else(|_| json_type_name(value).to_string())\n })\n}\n\nfn content_value_to_text(value: &Value) -> Option<String> {\n match value {\n Value::String(text) => Some(text.clone()),\n Value::Array(items) => {\n let mut output = String::new();\n for item in items {\n let Some(object) = item.as_object() else {\n continue;\n };\n if object.get(\"type\").and_then(Value::as_str) == Some(\"text\") {\n if let Some(text) = object.get(\"text\").and_then(Value::as_str) {\n output.push_str(text);\n }\n } else if let Some(text) = object\n .get(\"text\")\n .or_else(|| object.get(\"content\"))\n .or_else(|| object.get(\"value\"))\n .and_then(Value::as_str)\n {\n output.push_str(text);\n }\n }\n Some(output)\n }\n Value::Object(object) => object\n .get(\"text\")\n .or_else(|| object.get(\"content\"))\n .or_else(|| object.get(\"value\"))\n .and_then(Value::as_str)\n .map(ToString::to_string),\n _ => None,\n }\n}\n\nfn validate_content_has_jsonish_text(content: String, summary: &str) -> Result<String, String> {\n if content.contains('{') || content.contains('[') {\n Ok(content)\n } else {\n Err(format!(\n \"AI provider returned non-JSON message content. content_preview=\\\"{}\\\". {summary}\",\n preview_text(&content, 300)\n ))\n }\n}\n\nfn value_text_len(value: Option<&Value>) -> usize {\n value\n .and_then(content_value_to_text)\n .map(|text| text.chars().count())\n .unwrap_or(0)\n}\n\nfn json_type_or_string(value: &Value) -> String {\n value\n .as_str()\n .map(ToString::to_string)\n .unwrap_or_else(|| json_type_name(value).to_string())\n}\n\nfn json_type_name(value: &Value) -> &'static str {\n match value {\n Value::Null => \"null\",\n Value::Bool(_) => \"boolean\",\n Value::Number(_) => \"number\",\n Value::String(_) => \"string\",\n Value::Array(_) => \"array\",\n Value::Object(_) => \"object\",\n }\n}\n\nfn preview_text(text: &str, limit: usize) -> String {\n let mut preview = text\n .chars()\n .take(limit)\n .collect::<String>()\n .replace(['\\r', '\\n', '\\t'], \" \");\n if text.chars().count() > limit {\n preview.push_str(\"...\");\n }\n preview\n}\n\nfn short_response(text: &str) -> String {\n const LIMIT: usize = 500;\n if text.len() <= LIMIT {\n text.to_string()\n } else {\n format!(\"{}...\", &text[..LIMIT])\n }\n}\n\nfn redact_api_key(message: &str, api_key: &str) -> String {\n let trimmed = api_key.trim();\n if trimmed.is_empty() {\n message.to_string()\n } else {\n message.replace(trimmed, \"[redacted]\")\n }\n}\n\nfn parse_model_list(text: &str) -> Result<Vec<AIModelInfo>, String> {\n let value = serde_json::from_str::<Value>(text)\n .map_err(|error| format!(\"AI model discovery returned invalid JSON: {error}\"))?;\n let models = value\n .get(\"data\")\n .or_else(|| value.get(\"models\"))\n .unwrap_or(&value);\n let items = models\n .as_array()\n .ok_or_else(|| \"AI model discovery response did not contain a model array.\".to_string())?;\n let mut result = Vec::new();\n let mut seen = std::collections::HashSet::new();\n for item in items {\n let (id, owned_by) = match item {\n Value::String(id) => (id.trim().to_string(), None),\n Value::Object(object) => {\n let id = object\n .get(\"id\")\n .or_else(|| object.get(\"name\"))\n .and_then(Value::as_str)\n .unwrap_or_default()\n .trim()\n .to_string();\n let owned_by = object\n .get(\"owned_by\")\n .or_else(|| object.get(\"ownedBy\"))\n .and_then(Value::as_str)\n .map(ToString::to_string);\n (id, owned_by)\n }\n _ => (String::new(), None),\n };\n if !id.is_empty() && seen.insert(id.clone()) {\n result.push(AIModelInfo {\n id,\n owned_by,\n discovered: true,\n });\n }\n }\n Ok(result)\n}\n\n#[cfg(test)]\nmod tests {\n use super::*;\n use crate::ai::schema::AIProviderOptions;\n use std::{\n io::{Read, Write},\n net::TcpListener,\n thread,\n };\n\n #[test]\n fn extra_body_cannot_override_reserved_fields() {\n let mut body = Map::from_iter([\n (\"model\".to_string(), json!(\"trusted-model\")),\n (\n \"messages\".to_string(),\n json!([{\"role\":\"user\",\"content\":\"safe\"}]),\n ),\n (\"temperature\".to_string(), json!(0.2)),\n (\"max_tokens\".to_string(), json!(1024)),\n (\"response_format\".to_string(), json!({\"type\":\"json_object\"})),\n ]);\n\n merge_extra_body(\n &mut body,\n Some(r#\"{\"model\":\"attacker\",\"messages\":[],\"stream\":true,\"temperature\":2,\"max_tokens\":999999,\"max_completion_tokens\":999999,\"response_format\":{\"type\":\"text\"},\"thinking\":{\"type\":\"enabled\"},\"reasoning_effort\":\"high\",\"safe_extension\":true}\"#),\n \"\",\n )\n .expect(\"merge safe extension fields\");\n\n assert_eq!(body[\"model\"], \"trusted-model\");\n assert_eq!(body[\"messages\"][0][\"content\"], \"safe\");\n assert_eq!(body[\"temperature\"], 0.2);\n assert_eq!(body[\"max_tokens\"], 1024);\n assert_eq!(body[\"response_format\"][\"type\"], \"json_object\");\n for key in [\n \"stream\",\n \"max_completion_tokens\",\n \"thinking\",\n \"reasoning_effort\",\n ] {\n assert!(!body.contains_key(key), \"{key}\");\n }\n assert_eq!(body[\"safe_extension\"], true);\n }\n\n #[test]\n fn parse_openai_content_reads_normal_string_content() {\n let text =\n r#\"{\"choices\":[{\"finish_reason\":\"stop\",\"message\":{\"content\":\"{\\\"ok\\\":true}\"}}]}\"#;\n let content = parse_openai_content(text).expect(\"parse content\");\n assert_eq!(content, r#\"{\"ok\":true}\"#);\n }\n\n #[test]\n fn parse_openai_content_reports_empty_content() {\n let text = r#\"{\"choices\":[{\"finish_reason\":\"stop\",\"message\":{\"content\":\" \"}}]}\"#;\n let error = parse_openai_content(text).expect_err(\"empty content should fail\");\n assert!(error.contains(\"AI provider returned empty message.content.\"));\n assert!(error.contains(\"has_choices=true\"));\n assert!(error.contains(\"content_type=string\"));\n assert!(error.contains(\"content_length=2\"));\n }\n\n #[test]\n fn parse_openai_content_reports_reasoning_without_final_content() {\n let text = r#\"{\"choices\":[{\"finish_reason\":\"stop\",\"message\":{\"content\":\"\",\"reasoning_content\":\"thinking trace\"}}]}\"#;\n let error = parse_openai_content(text).expect_err(\"reasoning-only response should fail\");\n assert!(error.contains(\"AI provider returned reasoning_content but empty content.\"));\n assert!(error.contains(\"Please disable Thinking or use a non-thinking model.\"));\n assert!(error.contains(\"has_reasoning_content=true\"));\n assert!(error.contains(\"reasoning_content_length=14\"));\n }\n\n #[test]\n fn parse_openai_content_reads_array_text_content() {\n let text = r#\"{\"choices\":[{\"finish_reason\":\"stop\",\"message\":{\"content\":[{\"type\":\"text\",\"text\":\"{\\\"ok\\\":\"},{\"type\":\"text\",\"text\":\"true}\"}]}}]}\"#;\n let content = parse_openai_content(text).expect(\"parse text parts\");\n assert_eq!(content, r#\"{\"ok\":true}\"#);\n }\n\n #[test]\n fn parse_openai_content_reads_message_output_text() {\n let text =\n r#\"{\"choices\":[{\"finish_reason\":\"stop\",\"message\":{\"output_text\":\"{\\\"ok\\\":true}\"}}]}\"#;\n let content = parse_openai_content(text).expect(\"parse output_text\");\n assert_eq!(content, r#\"{\"ok\":true}\"#);\n }\n\n #[test]\n fn parse_openai_content_reads_message_text() {\n let text = r#\"{\"choices\":[{\"finish_reason\":\"stop\",\"message\":{\"text\":\"{\\\"ok\\\":true}\"}}]}\"#;\n let content = parse_openai_content(text).expect(\"parse text\");\n assert_eq!(content, r#\"{\"ok\":true}\"#);\n }\n\n #[test]\n fn model_discovery_parses_openai_data_and_deduplicates_ids() {\n let models = parse_model_list(\n r#\"{\"data\":[{\"id\":\"deepseek-v4-flash\",\"owned_by\":\"deepseek\"},{\"id\":\"deepseek-v4-flash\"},{\"id\":\"\"}]}\"#,\n )\n .expect(\"model list\");\n assert_eq!(models.len(), 1);\n assert_eq!(models[0].id, \"deepseek-v4-flash\");\n assert_eq!(models[0].owned_by.as_deref(), Some(\"deepseek\"));\n }\n\n #[test]\n fn deepseek_force_json_uses_json_object_response_format() {\n let provider = OpenAICompatibleProvider::new(AISettings::default());\n let (_, used_response_format, thinking) = provider\n .build_chat_body(&AIChatRequest {\n messages: vec![AIChatMessage {\n role: \"user\".to_string(),\n content: \"Return JSON.\".to_string(),\n }],\n model: \"deepseek-v4-flash\".to_string(),\n temperature: 0.0,\n max_tokens: 8192,\n force_json: true,\n provider_options: AIProviderOptions::default(),\n })\n .expect(\"build DeepSeek body\");\n let (body, _, _) = provider\n .build_chat_body(&AIChatRequest {\n messages: vec![AIChatMessage {\n role: \"user\".to_string(),\n content: \"Return JSON.\".to_string(),\n }],\n model: \"deepseek-v4-flash\".to_string(),\n temperature: 0.0,\n max_tokens: 8192,\n force_json: true,\n provider_options: AIProviderOptions::default(),\n })\n .expect(\"build DeepSeek body\");\n assert!(used_response_format);\n assert_eq!(body[\"response_format\"][\"type\"], \"json_object\");\n assert_eq!(thinking.as_deref(), Some(\"disabled\"));\n }\n\n #[test]\n fn json_only_retry_keeps_response_format_and_disables_thinking() {\n let settings = AISettings {\n enable_thinking: true,\n ..AISettings::default()\n };\n let provider = OpenAICompatibleProvider::new(settings);\n let (body, used_response_format, thinking) = provider\n .build_chat_body(&AIChatRequest {\n messages: Vec::new(),\n model: \"deepseek-v4-flash\".to_string(),\n temperature: 0.0,\n max_tokens: 512,\n force_json: true,\n provider_options: AIProviderOptions {\n enable_thinking: Some(false),\n use_response_format: Some(true),\n ..Default::default()\n },\n })\n .expect(\"build retry body\");\n assert!(used_response_format);\n assert_eq!(body[\"response_format\"][\"type\"], \"json_object\");\n assert_eq!(body[\"thinking\"][\"type\"], \"disabled\");\n assert_eq!(thinking.as_deref(), Some(\"disabled\"));\n }\n\n #[test]\n fn registry_profiles_select_provider_specific_payload_fields() {\n let minimax_settings = AISettings {\n preset: AIProviderPresetId::Minimax,\n base_url: \"https://api.minimaxi.com/v1\".to_string(),\n enable_thinking: true,\n ..AISettings::default()\n };\n let minimax = OpenAICompatibleProvider::new(minimax_settings);\n let (body, _, _) = minimax\n .build_chat_body(&AIChatRequest {\n messages: Vec::new(),\n model: \"MiniMax-M2.5\".to_string(),\n temperature: 1.5,\n max_tokens: 100,\n force_json: true,\n provider_options: AIProviderOptions::default(),\n })\n .expect(\"MiniMax body\");\n assert_eq!(body[\"max_completion_tokens\"], 100);\n assert_eq!(body[\"reasoning_split\"], true);\n assert_eq!(body[\"temperature\"], 1.0);\n\n let qwen_settings = AISettings {\n preset: AIProviderPresetId::QwenDashScope,\n base_url: \"https://dashscope.aliyuncs.com/compatible-mode/v1\".to_string(),\n enable_thinking: true,\n ..AISettings::default()\n };\n let qwen = OpenAICompatibleProvider::new(qwen_settings);\n let (body, _, _) = qwen\n .build_chat_body(&AIChatRequest {\n messages: Vec::new(),\n model: \"qwen-plus\".to_string(),\n temperature: 0.2,\n max_tokens: 100,\n force_json: true,\n provider_options: AIProviderOptions::default(),\n })\n .expect(\"Qwen body\");\n assert_eq!(body[\"response_format\"][\"type\"], \"json_object\");\n assert_eq!(body[\"enable_thinking\"], true);\n }\n\n #[test]\n fn finish_reason_length_is_reported_as_truncated_json() {\n let error = parse_openai_content(\n r#\"{\"choices\":[{\"finish_reason\":\"length\",\"message\":{\"content\":\"{\\\"classifications\\\":[\"}}]}\"#,\n )\n .expect_err(\"length-limited output must fail\");\n assert!(error.contains(\"模型输出达到长度上限,JSON 被截断。\"));\n assert!(error.contains(\"finish_reason=length\"));\n }\n\n #[test]\n fn empty_json_content_is_explicitly_retryable() {\n let error = parse_openai_content(\n r#\"{\"choices\":[{\"finish_reason\":\"stop\",\"message\":{\"content\":\"\"}}]}\"#,\n )\n .expect_err(\"empty content must fail\");\n assert!(error.contains(\"AI provider returned empty message.content.\"));\n assert!(error.contains(\"JSON content is empty and should be retried\"));\n }\n\n #[test]\n fn parse_openai_content_includes_short_non_json_preview() {\n let text = r#\"{\"choices\":[{\"finish_reason\":\"stop\",\"message\":{\"content\":\"Sure, I can classify that file.\"}}]}\"#;\n let error = parse_openai_content(text).expect_err(\"non-json content should fail\");\n assert!(error.contains(\"AI provider returned non-JSON message content.\"));\n assert!(error.contains(\"content_preview=\\\"Sure, I can classify that file.\\\"\"));\n assert!(error.contains(\"message_keys=[content]\"));\n }\n\n #[test]\n fn provider_error_redaction_removes_api_key_from_summary() {\n let api_key = \"sk-provider-secret\";\n let text = r#\"{\"choices\":[{\"finish_reason\":\"stop\",\"message\":{\"content\":\"Provider rejected sk-provider-secret\"}}]}\"#;\n let error = parse_openai_content(text).expect_err(\"non-json content should fail\");\n let redacted = redact_api_key(&error, api_key);\n assert!(!redacted.contains(api_key));\n assert!(redacted.contains(\"[redacted]\"));\n }\n\n #[test]\n fn provider_rejects_redirects_without_forwarding_credentials() {\n let listener = TcpListener::bind(\"127.0.0.1:0\").expect(\"bind redirect fixture\");\n let address = listener.local_addr().expect(\"redirect address\");\n let server = thread::spawn(move || {\n let (mut stream, _) = listener.accept().expect(\"accept redirect request\");\n let mut request = [0_u8; 4096];\n let size = stream.read(&mut request).expect(\"read redirect request\");\n let request = String::from_utf8_lossy(&request[..size]);\n assert!(request\n .to_ascii_lowercase()\n .contains(\"authorization: bearer sk-provider-secret\"));\n stream\n .write_all(\n b\"HTTP/1.1 307 Temporary Redirect\\r\\nLocation: http://127.0.0.1:9/second\\r\\nContent-Length: 0\\r\\n\\r\\n\",\n )\n .expect(\"write redirect response\");\n });\n\n let settings = AISettings {\n base_url: format!(\"http://{address}\"),\n api_key: \"sk-provider-secret\".to_string(),\n ..AISettings::default()\n };\n let provider = OpenAICompatibleProvider::new(settings);\n let error = provider\n .send_chat_request_raw(AIChatRequest {\n messages: vec![AIChatMessage {\n role: \"user\".to_string(),\n content: \"hello\".to_string(),\n }],\n model: \"test-model\".to_string(),\n temperature: 0.0,\n max_tokens: 16,\n force_json: false,\n provider_options: AIProviderOptions::default(),\n })\n .expect_err(\"redirect must be rejected\");\n server.join().expect(\"redirect server\");\n let message = error.to_string();\n assert!(message.contains(\"redirect rejected\"));\n assert!(!message.contains(\"sk-provider-secret\"));\n }\n}\n"}, "f64cf0097f514b7395356b1f3df7c6af1020a12d": {"t": 1785757167.108789, "n": 53628, "h": "8b7737ae9faf7763", "c": "use std::time::{Duration, Instant};\n\nuse reqwest::blocking::Client;\nuse serde_json::{json, Map, Value};\n\nuse super::{\n presets::{provider_preset, AIProviderPreset},\n prompts::{clean_ai_json_text, extract_first_json_value},\n provider::{AIProvider, AIProviderError},\n registry::{AIAuthKind, AIThinkingStrategy, AITokenParameter},\n schema::{\n AIChatMessage, AIChatRequest, AIConnectionTestResult, AIModelInfo, AIProviderOptions,\n AIProviderPresetId,\n },\n settings::AISettings,\n trace::{\n now_iso, record_trace, update_trace_with_secrets, AIRequestTrace, AITraceContext,\n AITraceMode, AITraceOperation, AITraceRequest, AITraceResponse, AITraceUpdate,\n AITraceUsage, SecretRedactor,\n },\n};\n\ntype ChatBody = (Map<String, Value>, bool, Option<String>);\n\npub struct OpenAICompatibleProvider {\n settings: AISettings,\n preset: AIProviderPreset,\n client: Option<Client>,\n}\n\n#[derive(Debug, Clone)]\npub struct AIRawProviderResponse {\n pub status: u16,\n pub response_text: String,\n pub request_used_response_format: bool,\n pub request_used_thinking_field: Option<String>,\n pub response_summary: String,\n pub trace_id: Option<String>,\n pub(crate) pending_trace: Option<AIRequestTrace>,\n}\n\n#[derive(Debug, Clone, serde::Serialize)]\npub struct AIDebugExtractResult {\n pub finish_reason: Option<String>,\n pub message_keys: Vec<String>,\n pub message_content: Option<String>,\n pub reasoning_content: Option<String>,\n pub output_text: Option<String>,\n pub text: Option<String>,\n pub extracted_content: Option<String>,\n pub parse_error: Option<String>,\n}\n\nimpl OpenAICompatibleProvider {\n pub fn new(settings: AISettings) -> Self {\n let mut preset = provider_preset(settings.preset)\n .or_else(|| provider_preset(AIProviderPresetId::CustomOpenAICompatible))\n .expect(\"custom OpenAI-compatible preset exists\");\n if let Some(profile) = settings\n .active_custom_profile_id\n .as_deref()\n .and_then(|profile_id| {\n settings\n .custom_profiles\n .iter()\n .find(|profile| profile.id == profile_id)\n })\n {\n preset.capabilities.supports_response_format_json_object =\n profile.supports_response_format;\n preset.capabilities.supports_thinking = profile.supports_thinking;\n preset.capabilities.supports_thinking_toggle = profile.supports_thinking;\n preset.capabilities.supports_reasoning_effort = profile\n .thinking_parameter\n .eq_ignore_ascii_case(\"reasoning_effort\");\n preset.supports_response_format = profile.supports_response_format;\n preset.supports_json_mode = profile.supports_response_format;\n preset.supports_thinking = profile.supports_thinking;\n preset.supports_reasoning_effort = profile\n .thinking_parameter\n .eq_ignore_ascii_case(\"reasoning_effort\");\n preset.parameter_profile.token_parameter = if profile\n .token_parameter\n .eq_ignore_ascii_case(\"max_completion_tokens\")\n {\n AITokenParameter::MaxCompletionTokens\n } else {\n AITokenParameter::MaxTokens\n };\n preset.parameter_profile.thinking_strategy =\n match profile.thinking_parameter.to_ascii_lowercase().as_str() {\n \"none\" => AIThinkingStrategy::None,\n \"enable_thinking\" | \"boolean\" => AIThinkingStrategy::EnableThinkingBoolean,\n \"reasoning_effort\" => AIThinkingStrategy::ReasoningEffort,\n \"minimax_reasoning_split\" | \"reasoning_split\" => {\n AIThinkingStrategy::MiniMaxReasoningSplit\n }\n \"prompt_only\" => AIThinkingStrategy::PromptOnly,\n _ => AIThinkingStrategy::GenericThinkingObject,\n };\n }\n let client = Client::builder()\n .timeout(Duration::from_secs(settings.timeout_seconds.max(1)))\n .redirect(reqwest::redirect::Policy::none())\n .build()\n .ok();\n Self {\n settings,\n preset,\n client,\n }\n }\n\n fn request_url(&self) -> Result<String, AIProviderError> {\n join_base_url_and_chat_path(&self.settings.base_url, &self.settings.chat_path)\n }\n\n fn client(&self) -> Result<&Client, AIProviderError> {\n self.client\n .as_ref()\n .ok_or_else(|| self.error(\"failed to build AI HTTP client\"))\n }\n\n fn authorize_request(\n &self,\n builder: reqwest::blocking::RequestBuilder,\n ) -> reqwest::blocking::RequestBuilder {\n let api_key = self.settings.api_key.trim();\n if api_key.is_empty() {\n return builder;\n }\n match self.preset.auth_kind {\n AIAuthKind::BearerApiKey | AIAuthKind::QianfanAkSk => builder.bearer_auth(api_key),\n AIAuthKind::ApiKeyHeader => builder.header(\"X-API-Key\", api_key),\n AIAuthKind::None => builder,\n }\n }\n\n fn trace_secrets(&self, request: &AIChatRequest) -> Vec<String> {\n let mut secrets = vec![self.settings.api_key.clone()];\n if let Some(context) = request.provider_options.trace_context.as_ref() {\n secrets.extend(context.redaction_secrets.iter().cloned());\n }\n secrets\n }\n\n fn error(&self, message: impl Into<String>) -> AIProviderError {\n AIProviderError::new(redact_api_key(&message.into(), &self.settings.api_key))\n }\n\n pub fn send_chat_request_raw(\n &self,\n request: AIChatRequest,\n ) -> Result<AIRawProviderResponse, AIProviderError> {\n let started = Instant::now();\n let url = self.request_url()?;\n let (body, request_used_response_format, request_used_thinking_field) =\n self.build_chat_body(&request)?;\n let trace_mode = self.settings.diagnostics_mode;\n let mut trace = self.build_trace(\n &request,\n &body,\n &url,\n request_used_response_format,\n request_used_thinking_field.clone(),\n );\n\n let mut builder = self\n .client()?\n .post(&url)\n .header(reqwest::header::CONTENT_TYPE, \"application/json\");\n builder = self.authorize_request(builder);\n\n let response = match builder.json(&Value::Object(body)).send() {\n Ok(response) => response,\n Err(error) => {\n let message = self.error(format!(\"AI request failed: {error}\"));\n trace.elapsed_ms = started.elapsed().as_millis();\n trace.parse_stage = \"transport_error\".to_string();\n trace.error_code = Some(\"transport_error\".to_string());\n trace.error_message = Some(message.to_string());\n record_trace(trace_mode, trace, true);\n return Err(message);\n }\n };\n let status = response.status();\n if status.is_redirection() {\n let message = self.error(format!(\n \"AI provider redirect rejected: HTTP {}\",\n status.as_u16()\n ));\n trace.elapsed_ms = started.elapsed().as_millis();\n trace.response.http_status = Some(status.as_u16());\n trace.parse_stage = \"transport_error\".to_string();\n trace.error_code = Some(\"redirect_rejected\".to_string());\n trace.error_message = Some(message.to_string());\n record_trace(trace_mode, trace, true);\n return Err(message);\n }\n let response_text = response\n .text()\n .map_err(|error| self.error(format!(\"failed to read AI response: {error}\")))?;\n let trace_secrets = self.trace_secrets(&request);\n let redactor = SecretRedactor::new(trace_secrets.iter().map(String::as_str));\n let (redacted_response, raw_truncated) = redactor.redact_optional_text(\n Some(&response_text),\n super::trace::MAX_RAW_PROVIDER_RESPONSE_CHARS,\n );\n let redacted_response = redacted_response.unwrap_or_default();\n let response_summary = serde_json::from_str::<Value>(&response_text)\n .map(|value| summarize_provider_response(&value))\n .unwrap_or_else(|error| format!(\"provider response summary: invalid_json={error}\"));\n\n trace.elapsed_ms = started.elapsed().as_millis();\n trace.response = response_trace_metadata(status.as_u16(), &response_text);\n trace.raw_provider_response = Some(redacted_response.clone());\n trace.truncated |= raw_truncated;\n trace.parse_stage = if status.is_success() {\n \"provider_response\".to_string()\n } else {\n \"http_error\".to_string()\n };\n if !status.is_success() {\n trace.error_code = Some(format!(\"http_{}\", status.as_u16()));\n trace.error_message = Some(format!(\n \"AI provider returned HTTP {}: {}\",\n status.as_u16(),\n short_response(&redacted_response)\n ));\n }\n let trace_id = record_trace(trace_mode, trace.clone(), !status.is_success());\n let pending_trace = if trace_id.is_none() && !matches!(trace_mode, AITraceMode::Off) {\n Some(trace)\n } else {\n None\n };\n\n Ok(AIRawProviderResponse {\n status: status.as_u16(),\n response_text: redacted_response,\n request_used_response_format,\n request_used_thinking_field,\n response_summary,\n trace_id,\n pending_trace,\n })\n }\n\n fn build_trace(\n &self,\n request: &AIChatRequest,\n body: &Map<String, Value>,\n url: &str,\n request_used_response_format: bool,\n request_used_thinking_field: Option<String>,\n ) -> AIRequestTrace {\n let parsed_url = url::Url::parse(url).ok();\n let mut extra_body_keys = body\n .keys()\n .filter(|key| {\n !matches!(\n key.as_str(),\n \"model\"\n | \"messages\"\n | \"stream\"\n | \"temperature\"\n | \"max_tokens\"\n | \"max_completion_tokens\"\n | \"response_format\"\n | \"thinking\"\n | \"enable_thinking\"\n | \"reasoning_split\"\n | \"reasoning_effort\"\n )\n })\n .cloned()\n .collect::<Vec<_>>();\n extra_body_keys.sort();\n let context = request\n .provider_options\n .trace_context\n .clone()\n .unwrap_or_default();\n AIRequestTrace {\n trace_id: String::new(),\n job_id: context.job_id.clone(),\n batch_id: context.batch_id.clone(),\n started_at: now_iso(),\n elapsed_ms: 0,\n operation: context.operation,\n provider_id: format!(\"{:?}\", self.preset.id),\n provider_label: self.preset.label.to_string(),\n model: request.model.clone(),\n request: AITraceRequest {\n url_host: parsed_url\n .as_ref()\n .and_then(url::Url::host_str)\n .unwrap_or_default()\n .to_string(),\n path: parsed_url\n .as_ref()\n .map(|url| url.path().to_string())\n .unwrap_or_else(|| \"/\".to_string()),\n message_count: request.messages.len(),\n target_count: context.target_count,\n batch_size: context.batch_size,\n max_tokens: Some(request.max_tokens),\n temperature: body\n .get(\"temperature\")\n .and_then(Value::as_f64)\n .map(|temperature| temperature as f32),\n force_json: request.force_json,\n response_format: request_used_response_format.then(|| \"json_object\".to_string()),\n thinking_mode: request_used_thinking_field,\n extra_body_keys,\n },\n parse_stage: \"request_sent\".to_string(),\n ..AIRequestTrace::default()\n }\n }\n\n fn build_chat_body(&self, request: &AIChatRequest) -> Result<ChatBody, AIProviderError> {\n let mut body = Map::new();\n body.insert(\"model\".to_string(), json!(request.model));\n body.insert(\n \"messages\".to_string(),\n json!(messages_with_instructions(\n &request.messages,\n request.force_json,\n thinking_enabled(&self.settings, request),\n self.preset.id\n )),\n );\n let temperature = request.temperature.clamp(\n self.preset.parameter_profile.temperature_min as f32,\n self.preset.parameter_profile.temperature_max as f32,\n );\n body.insert(\"temperature\".to_string(), json!(temperature));\n let token_field = match self.preset.parameter_profile.token_parameter {\n AITokenParameter::MaxTokens => \"max_tokens\",\n AITokenParameter::MaxCompletionTokens => \"max_completion_tokens\",\n };\n body.insert(token_field.to_string(), json!(request.max_tokens));\n\n let response_format_enabled = request.provider_options.use_response_format.unwrap_or(\n self.preset\n .capabilities\n .supports_response_format_json_object,\n );\n if request.force_json && response_format_enabled {\n body.insert(\n \"response_format\".to_string(),\n json!({ \"type\": \"json_object\" }),\n );\n }\n\n // Advanced fields may extend the request, but core request semantics stay authoritative.\n merge_extra_body(\n &mut body,\n self.settings.extra_body_json.as_deref(),\n &self.settings.api_key,\n )\n .map_err(|error| self.error(error.to_string()))?;\n merge_extra_body(\n &mut body,\n request.provider_options.extra_body_json.as_deref(),\n &self.settings.api_key,\n )\n .map_err(|error| self.error(error.to_string()))?;\n\n let enable_thinking = thinking_enabled(&self.settings, request);\n if enable_thinking && self.preset.supports_reasoning_effort {\n if let Some(reasoning_effort) = request\n .provider_options\n .reasoning_effort\n .as_deref()\n .or(self.settings.reasoning_effort.as_deref())\n .filter(|value| !value.trim().is_empty())\n {\n body.insert(\"reasoning_effort\".to_string(), json!(reasoning_effort));\n }\n }\n match self.preset.parameter_profile.thinking_strategy {\n AIThinkingStrategy::DeepSeekThinkingObject\n if self.preset.capabilities.supports_thinking_toggle =>\n {\n body.insert(\n \"thinking\".to_string(),\n json!({ \"type\": if enable_thinking { \"enabled\" } else { \"disabled\" } }),\n );\n }\n AIThinkingStrategy::GenericThinkingObject\n if self.preset.capabilities.supports_thinking_toggle =>\n {\n body.insert(\n \"thinking\".to_string(),\n json!({ \"type\": if enable_thinking { \"enabled\" } else { \"disabled\" } }),\n );\n }\n AIThinkingStrategy::EnableThinkingBoolean\n if self.preset.capabilities.supports_thinking_toggle =>\n {\n body.insert(\"enable_thinking\".to_string(), json!(enable_thinking));\n }\n AIThinkingStrategy::MiniMaxReasoningSplit => {\n body.insert(\"reasoning_split\".to_string(), json!(enable_thinking));\n }\n _ => {}\n }\n\n let request_used_thinking_field = body.get(\"thinking\").map(thinking_field_text);\n let request_used_thinking_field = request_used_thinking_field.or_else(|| {\n body.get(\"enable_thinking\").map(|value| {\n value\n .as_bool()\n .map(|enabled| enabled.to_string())\n .unwrap_or_else(|| value.to_string())\n })\n });\n Ok((\n body,\n request.force_json && response_format_enabled,\n request_used_thinking_field,\n ))\n }\n}\n\nimpl AIProvider for OpenAICompatibleProvider {\n fn chat_json(&self, request: AIChatRequest) -> Result<String, AIProviderError> {\n let raw = self.send_chat_request_raw(request.clone())?;\n if !(200..300).contains(&raw.status) {\n let mut message = format!(\n \"AI provider returned HTTP {}: {}\",\n raw.status,\n short_response(&raw.response_text)\n );\n if self.preset.id == AIProviderPresetId::DeepSeek\n && !thinking_enabled(&self.settings, &request)\n {\n message.push_str(\n \" If DeepSeek rejects thinking disabled, use a non-thinking model or a compatible legacy model name.\",\n );\n }\n let error = self.error(message);\n if let Some(trace_id) = raw.trace_id.as_deref() {\n let trace_secrets = self.trace_secrets(&request);\n update_trace_with_secrets(\n trace_id,\n AITraceUpdate {\n parse_stage: Some(\"http_error\".to_string()),\n error_code: Some(format!(\"http_{}\", raw.status)),\n error_message: Some(error.to_string()),\n ..AITraceUpdate::default()\n },\n trace_secrets.iter().map(String::as_str),\n );\n }\n return Err(error);\n }\n\n match parse_openai_content(&raw.response_text) {\n Ok(content) => {\n let cleaned_json_text = clean_ai_json_text(&content);\n let parsed_json = serde_json::from_str::<Value>(&cleaned_json_text)\n .ok()\n .or_else(|| {\n extract_first_json_value(&content)\n .and_then(|value| serde_json::from_str::<Value>(&value).ok())\n });\n if let Some(trace_id) = raw.trace_id.as_deref() {\n let trace_secrets = self.trace_secrets(&request);\n update_trace_with_secrets(\n trace_id,\n AITraceUpdate {\n extracted_content: Some(content.clone()),\n cleaned_json_text: Some(cleaned_json_text),\n parsed_json,\n parse_stage: Some(\"extracted_content\".to_string()),\n ..AITraceUpdate::default()\n },\n trace_secrets.iter().map(String::as_str),\n );\n }\n Ok(content)\n }\n Err(error) => {\n let error = self.error(error);\n let trace_secrets = self.trace_secrets(&request);\n if let Some(trace_id) = raw.trace_id.as_deref() {\n update_trace_with_secrets(\n trace_id,\n AITraceUpdate {\n parse_stage: Some(\"parse_error\".to_string()),\n error_code: Some(\"response_parse_error\".to_string()),\n error_message: Some(error.to_string()),\n ..AITraceUpdate::default()\n },\n trace_secrets.iter().map(String::as_str),\n );\n } else if let Some(mut trace) = raw.pending_trace {\n trace.parse_stage = \"parse_error\".to_string();\n trace.error_code = Some(\"response_parse_error\".to_string());\n trace.error_message = Some(\n SecretRedactor::new(trace_secrets.iter().map(String::as_str))\n .redact_text(&error.to_string()),\n );\n record_trace(self.settings.diagnostics_mode, trace, true);\n }\n Err(error)\n }\n }\n }\n\n fn test_connection(&self) -> Result<AIConnectionTestResult, AIProviderError> {\n let mut prompt = \"Return exactly this JSON and nothing else: {\\\"ok\\\":true}\\nDo not output Markdown.\\nDo not output reasoning.\\nDo not output <think>.\\nDo not explain.\".to_string();\n if self.preset.id == AIProviderPresetId::DeepSeek && !self.settings.enable_thinking {\n prompt.push_str(\"\\nUse non-thinking mode and only return final content.\");\n }\n let test_max_tokens = self.settings.max_tokens.clamp(512, 4096);\n let content = self.chat_json(AIChatRequest {\n messages: vec![AIChatMessage {\n role: \"user\".to_string(),\n content: prompt,\n }],\n model: self.settings.model.clone(),\n temperature: 0.0,\n max_tokens: test_max_tokens,\n force_json: true,\n provider_options: AIProviderOptions {\n trace_context: Some(AITraceContext {\n operation: AITraceOperation::ConnectionTest,\n ..Default::default()\n }),\n ..Default::default()\n },\n })?;\n Ok(AIConnectionTestResult {\n ok: true,\n message: format!(\"AI provider responded: {}\", short_response(&content)),\n model: Some(self.settings.model.clone()),\n provider: Some(self.settings.provider),\n preset: Some(self.settings.preset),\n elapsed_ms: 0,\n })\n }\n\n fn discover_models(&self) -> Result<Vec<AIModelInfo>, AIProviderError> {\n let models_path = self\n .settings\n .models_path\n .as_deref()\n .or(self.preset.models_path)\n .filter(|path| !path.trim().is_empty())\n .ok_or_else(|| {\n self.error(\"This provider does not expose an OpenAI-compatible models endpoint.\")\n })?;\n let url = join_base_url_and_chat_path(&self.settings.base_url, models_path)?;\n let mut builder = self\n .client()?\n .get(url)\n .header(reqwest::header::ACCEPT, \"application/json\");\n builder = self.authorize_request(builder);\n let response = builder\n .send()\n .map_err(|error| self.error(format!(\"AI model discovery request failed: {error}\")))?;\n let status = response.status();\n let text = response.text().map_err(|error| {\n self.error(format!(\n \"failed to read AI model discovery response: {error}\"\n ))\n })?;\n if !status.is_success() {\n return Err(self.error(format!(\n \"AI model discovery returned HTTP {}: {}\",\n status.as_u16(),\n short_response(&redact_api_key(&text, &self.settings.api_key))\n )));\n }\n parse_model_list(&text).map_err(|error| self.error(error))\n }\n}\n\npub fn join_base_url_and_chat_path(\n base_url: &str,\n chat_path: &str,\n) -> Result<String, AIProviderError> {\n let base = base_url.trim().trim_end_matches('/');\n let path = chat_path.trim().trim_start_matches('/');\n if base.is_empty() {\n return Err(AIProviderError::new(\"AI provider base_url is empty.\"));\n }\n if path.is_empty() {\n return Err(AIProviderError::new(\"AI provider chat_path is empty.\"));\n }\n Ok(format!(\"{base}/{path}\"))\n}\n\nfn messages_with_instructions(\n messages: &[AIChatMessage],\n force_json: bool,\n enable_thinking: bool,\n preset_id: AIProviderPresetId,\n) -> Vec<AIChatMessage> {\n let mut output = Vec::new();\n let mut instructions = Vec::new();\n if force_json {\n instructions.push(\"Return JSON only; the final content must be one complete valid JSON object. Include the word JSON. Do not wrap JSON in markdown or code fences.\".to_string());\n }\n if !enable_thinking {\n instructions.push(\"Do not output thinking, reasoning traces, or explanations; return the final JSON content directly.\".to_string());\n }\n if preset_id == AIProviderPresetId::DeepSeek {\n instructions.push(\n \"DeepSeek legacy model names deepseek-chat and deepseek-reasoner may be deprecated; keep output compatible with the selected model.\".to_string(),\n );\n }\n if !instructions.is_empty() {\n output.push(AIChatMessage {\n role: \"system\".to_string(),\n content: instructions.join(\" \"),\n });\n }\n output.extend_from_slice(messages);\n output\n}\n\nfn thinking_enabled(settings: &AISettings, request: &AIChatRequest) -> bool {\n request\n .provider_options\n .enable_thinking\n .unwrap_or(settings.enable_thinking)\n}\n\nfn merge_extra_body(\n body: &mut Map<String, Value>,\n extra_body_json: Option<&str>,\n api_key: &str,\n) -> Result<(), AIProviderError> {\n let Some(extra_body_json) = extra_body_json.filter(|value| !value.trim().is_empty()) else {\n return Ok(());\n };\n let value = serde_json::from_str::<Value>(extra_body_json).map_err(|error| {\n AIProviderError::new(redact_api_key(\n &format!(\"AI extra_body_json must be a JSON object: {error}\"),\n api_key,\n ))\n })?;\n let Value::Object(extra) = value else {\n return Err(AIProviderError::new(\n \"AI extra_body_json must be a JSON object.\",\n ));\n };\n for (key, value) in extra {\n if matches!(\n key.as_str(),\n \"model\"\n | \"messages\"\n | \"stream\"\n | \"temperature\"\n | \"max_tokens\"\n | \"max_completion_tokens\"\n | \"response_format\"\n | \"thinking\"\n | \"reasoning_effort\"\n | \"enable_thinking\"\n | \"reasoning_split\"\n | \"reasoning\"\n ) {\n continue;\n }\n body.insert(key, value);\n }\n Ok(())\n}\n\nfn parse_openai_content(text: &str) -> Result<String, String> {\n let value = serde_json::from_str::<Value>(text)\n .map_err(|error| format!(\"AI provider returned invalid JSON: {error}\"))?;\n let summary = summarize_provider_response(&value);\n let finish_reason = response_finish_reason(&value);\n if matches!(finish_reason.as_deref(), Some(\"length\" | \"max_tokens\")) {\n return Err(format!(\n \"模型输出达到长度上限,JSON 被截断。AI provider finish_reason={}。请提高 max_tokens 或降低 batch size。 {summary}\",\n finish_reason.as_deref().unwrap_or(\"length\")\n ));\n }\n if matches!(finish_reason.as_deref(), Some(\"content_filter\" | \"safety\")) {\n return Err(format!(\n \"AI provider blocked the response because of content filtering (finish_reason={}). {summary}\",\n finish_reason.as_deref().unwrap_or(\"content_filter\")\n ));\n }\n\n let reasoning = response_reasoning_text(&value);\n let content = response_content_text(&value);\n match content {\n Some(content) if !content.trim().is_empty() => {\n validate_content_has_jsonish_text(strip_think_tags_if_needed(content), &summary)\n }\n Some(_) if !reasoning.trim().is_empty() => Err(format!(\n \"AI provider returned reasoning_content but empty content. Please disable Thinking or use a non-thinking model. {summary}\"\n )),\n Some(_) => Err(format!(\n \"AI provider returned empty message.content. JSON content is empty and should be retried. {summary}\"\n )),\n None if !reasoning.trim().is_empty() => Err(format!(\n \"AI provider returned reasoning_content but no final content. Please disable Thinking or use a non-thinking model. {summary}\"\n )),\n None => Err(format!(\n \"AI provider response did not contain message.content, message.output_text, or message.text. {summary}\"\n )),\n }\n}\n\nfn response_finish_reason(value: &Value) -> Option<String> {\n value\n .get(\"choices\")\n .and_then(Value::as_array)\n .and_then(|choices| choices.first())\n .and_then(|choice| choice.get(\"finish_reason\"))\n .or_else(|| value.get(\"finish_reason\"))\n .and_then(Value::as_str)\n .map(ToString::to_string)\n}\n\nfn response_content_text(value: &Value) -> Option<String> {\n let choice = value\n .get(\"choices\")\n .and_then(Value::as_array)\n .and_then(|choices| choices.first());\n let message = choice.and_then(|choice| choice.get(\"message\"));\n for candidate in [\n message.and_then(|message| message.get(\"content\")),\n message.and_then(|message| message.get(\"output_text\")),\n message.and_then(|message| message.get(\"text\")),\n choice.and_then(|choice| choice.get(\"text\")),\n value.get(\"output_text\"),\n value.get(\"content\"),\n value.get(\"output\").and_then(|output| output.get(\"text\")),\n value.get(\"output\").and_then(|output| output.get(\"content\")),\n ] {\n if let Some(candidate) = candidate {\n if let Some(text) = content_value_to_text(candidate) {\n return Some(text);\n }\n }\n }\n None\n}\n\nfn response_reasoning_text(value: &Value) -> String {\n let choice = value\n .get(\"choices\")\n .and_then(Value::as_array)\n .and_then(|choices| choices.first());\n let message = choice.and_then(|choice| choice.get(\"message\"));\n [\n message.and_then(|message| message.get(\"reasoning_content\")),\n message.and_then(|message| message.get(\"reasoning_details\")),\n choice.and_then(|choice| choice.get(\"reasoning_content\")),\n value.get(\"reasoning_content\"),\n value.get(\"reasoning_details\"),\n ]\n .into_iter()\n .flatten()\n .filter_map(content_value_to_text)\n .collect::<Vec<_>>()\n .join(\"\\n\")\n}\n\nfn response_trace_metadata(status: u16, text: &str) -> AITraceResponse {\n let Ok(value) = serde_json::from_str::<Value>(text) else {\n return AITraceResponse {\n http_status: Some(status),\n ..AITraceResponse::default()\n };\n };\n let choice = value\n .get(\"choices\")\n .and_then(Value::as_array)\n .and_then(|choices| choices.first());\n let message = choice\n .and_then(|choice| choice.get(\"message\"))\n .and_then(Value::as_object);\n let mut message_keys = message\n .map(|message| message.keys().cloned().collect::<Vec<_>>())\n .unwrap_or_default();\n message_keys.sort();\n let content_value = message\n .and_then(|message| message.get(\"content\"))\n .or_else(|| value.get(\"content\"));\n let reasoning_value = message\n .and_then(|message| message.get(\"reasoning_content\"))\n .or_else(|| message.and_then(|message| message.get(\"reasoning_details\")))\n .or_else(|| value.get(\"reasoning_content\"));\n let usage = value.get(\"usage\").map(|usage| AITraceUsage {\n prompt_tokens: usage_number(usage, &[\"prompt_tokens\", \"input_tokens\"]),\n completion_tokens: usage_number(usage, &[\"completion_tokens\", \"output_tokens\"]),\n total_tokens: usage_number(usage, &[\"total_tokens\"]),\n });\n AITraceResponse {\n http_status: Some(status),\n finish_reason: response_finish_reason(&value),\n message_keys,\n content_type: content_value.map(json_type_name).map(ToString::to_string),\n content_length: Some(value_text_len(content_value)),\n reasoning_content_length: Some(value_text_len(reasoning_value)),\n usage,\n }\n}\n\nfn usage_number(value: &Value, keys: &[&str]) -> Option<u64> {\n keys.iter()\n .find_map(|key| value.get(*key).and_then(Value::as_u64))\n}\n\nfn strip_think_tags_if_needed(content: String) -> String {\n let mut output = content;\n loop {\n let Some(start) = output.to_ascii_lowercase().find(\"<think>\") else {\n break;\n };\n let Some(relative_end) = output[start..].to_ascii_lowercase().find(\"</think>\") else {\n break;\n };\n let end = start + relative_end + \"</think>\".len();\n output.replace_range(start..end, \"\");\n }\n output.trim().to_string()\n}\n\npub fn debug_extract_openai_response(raw_response: &str) -> AIDebugExtractResult {\n let value = match serde_json::from_str::<Value>(raw_response) {\n Ok(value) => value,\n Err(error) => {\n return AIDebugExtractResult {\n finish_reason: None,\n message_keys: Vec::new(),\n message_content: None,\n reasoning_content: None,\n output_text: None,\n text: None,\n extracted_content: None,\n parse_error: Some(format!(\"provider raw response is not JSON: {error}\")),\n };\n }\n };\n let choice = value\n .get(\"choices\")\n .and_then(Value::as_array)\n .and_then(|choices| choices.first());\n let message = choice\n .and_then(|choice| choice.get(\"message\"))\n .and_then(Value::as_object);\n let mut message_keys = message\n .map(|message| message.keys().cloned().collect::<Vec<_>>())\n .unwrap_or_default();\n message_keys.sort();\n let message_content = message\n .and_then(|message| message.get(\"content\"))\n .and_then(content_value_to_text);\n let output_text = message\n .and_then(|message| message.get(\"output_text\"))\n .and_then(content_value_to_text);\n let text = message\n .and_then(|message| message.get(\"text\"))\n .and_then(content_value_to_text);\n let extracted_content = message_content\n .clone()\n .or_else(|| output_text.clone())\n .or_else(|| text.clone());\n\n AIDebugExtractResult {\n finish_reason: choice\n .and_then(|choice| choice.get(\"finish_reason\"))\n .and_then(Value::as_str)\n .map(ToString::to_string),\n message_keys,\n message_content,\n reasoning_content: message\n .and_then(|message| message.get(\"reasoning_content\"))\n .and_then(content_value_to_text),\n output_text,\n text,\n extracted_content,\n parse_error: message\n .is_none()\n .then(|| \"provider response did not contain choices[0].message\".to_string()),\n }\n}\n\npub(crate) fn summarize_provider_response(value: &Value) -> String {\n let choices = value.get(\"choices\").and_then(Value::as_array);\n let choice = choices.and_then(|items| items.first());\n let message = choice\n .and_then(|choice| choice.get(\"message\"))\n .and_then(Value::as_object);\n let mut message_keys = message\n .map(|message| message.keys().cloned().collect::<Vec<_>>())\n .unwrap_or_default();\n message_keys.sort();\n let content = message.and_then(|message| message.get(\"content\"));\n let reasoning_content = message.and_then(|message| message.get(\"reasoning_content\"));\n format!(\n \"provider response summary: has_choices={}; choice_count={}; finish_reason={}; message_keys={}; content_type={}; content_length={}; has_reasoning_content={}; reasoning_content_length={}\",\n choices.is_some(),\n choices.map_or(0, Vec::len),\n choice\n .and_then(|choice| choice.get(\"finish_reason\"))\n .map(json_type_or_string)\n .unwrap_or_else(|| \"missing\".to_string()),\n if message_keys.is_empty() {\n \"[]\".to_string()\n } else {\n format!(\"[{}]\", message_keys.join(\",\"))\n },\n content.map(json_type_name).unwrap_or(\"missing\"),\n value_text_len(content),\n reasoning_content.is_some(),\n value_text_len(reasoning_content),\n )\n}\n\nfn thinking_field_text(value: &Value) -> String {\n value\n .get(\"type\")\n .and_then(Value::as_str)\n .map(ToString::to_string)\n .unwrap_or_else(|| {\n serde_json::to_string(value).unwrap_or_else(|_| json_type_name(value).to_string())\n })\n}\n\nfn content_value_to_text(value: &Value) -> Option<String> {\n match value {\n Value::String(text) => Some(text.clone()),\n Value::Array(items) => {\n let mut output = String::new();\n for item in items {\n let Some(object) = item.as_object() else {\n continue;\n };\n if object.get(\"type\").and_then(Value::as_str) == Some(\"text\") {\n if let Some(text) = object.get(\"text\").and_then(Value::as_str) {\n output.push_str(text);\n }\n } else if let Some(text) = object\n .get(\"text\")\n .or_else(|| object.get(\"content\"))\n .or_else(|| object.get(\"value\"))\n .and_then(Value::as_str)\n {\n output.push_str(text);\n }\n }\n Some(output)\n }\n Value::Object(object) => object\n .get(\"text\")\n .or_else(|| object.get(\"content\"))\n .or_else(|| object.get(\"value\"))\n .and_then(Value::as_str)\n .map(ToString::to_string),\n _ => None,\n }\n}\n\nfn validate_content_has_jsonish_text(content: String, summary: &str) -> Result<String, String> {\n if content.contains('{') || content.contains('[') {\n Ok(content)\n } else {\n Err(format!(\n \"AI provider returned non-JSON message content. content_preview=\\\"{}\\\". {summary}\",\n preview_text(&content, 300)\n ))\n }\n}\n\nfn value_text_len(value: Option<&Value>) -> usize {\n value\n .and_then(content_value_to_text)\n .map(|text| text.chars().count())\n .unwrap_or(0)\n}\n\nfn json_type_or_string(value: &Value) -> String {\n value\n .as_str()\n .map(ToString::to_string)\n .unwrap_or_else(|| json_type_name(value).to_string())\n}\n\nfn json_type_name(value: &Value) -> &'static str {\n match value {\n Value::Null => \"null\",\n Value::Bool(_) => \"boolean\",\n Value::Number(_) => \"number\",\n Value::String(_) => \"string\",\n Value::Array(_) => \"array\",\n Value::Object(_) => \"object\",\n }\n}\n\nfn preview_text(text: &str, limit: usize) -> String {\n let mut preview = text\n .chars()\n .take(limit)\n .collect::<String>()\n .replace(['\\r', '\\n', '\\t'], \" \");\n if text.chars().count() > limit {\n preview.push_str(\"...\");\n }\n preview\n}\n\nfn short_response(text: &str) -> String {\n const LIMIT: usize = 500;\n if text.len() <= LIMIT {\n text.to_string()\n } else {\n format!(\"{}...\", &text[..LIMIT])\n }\n}\n\nfn redact_api_key(message: &str, api_key: &str) -> String {\n let trimmed = api_key.trim();\n if trimmed.is_empty() {\n message.to_string()\n } else {\n message.replace(trimmed, \"[redacted]\")\n }\n}\n\nfn parse_model_list(text: &str) -> Result<Vec<AIModelInfo>, String> {\n let value = serde_json::from_str::<Value>(text)\n .map_err(|error| format!(\"AI model discovery returned invalid JSON: {error}\"))?;\n let models = value\n .get(\"data\")\n .or_else(|| value.get(\"models\"))\n .unwrap_or(&value);\n let items = models\n .as_array()\n .ok_or_else(|| \"AI model discovery response did not contain a model array.\".to_string())?;\n let mut result = Vec::new();\n let mut seen = std::collections::HashSet::new();\n for item in items {\n let (id, owned_by) = match item {\n Value::String(id) => (id.trim().to_string(), None),\n Value::Object(object) => {\n let id = object\n .get(\"id\")\n .or_else(|| object.get(\"name\"))\n .and_then(Value::as_str)\n .unwrap_or_default()\n .trim()\n .to_string();\n let owned_by = object\n .get(\"owned_by\")\n .or_else(|| object.get(\"ownedBy\"))\n .and_then(Value::as_str)\n .map(ToString::to_string);\n (id, owned_by)\n }\n _ => (String::new(), None),\n };\n if !id.is_empty() && seen.insert(id.clone()) {\n result.push(AIModelInfo {\n id,\n owned_by,\n discovered: true,\n });\n }\n }\n Ok(result)\n}\n\n#[cfg(test)]\nmod tests {\n use super::*;\n use crate::ai::schema::AIProviderOptions;\n use std::{\n io::{Read, Write},\n net::TcpListener,\n thread,\n };\n\n #[test]\n fn extra_body_cannot_override_reserved_fields() {\n let mut body = Map::from_iter([\n (\"model\".to_string(), json!(\"trusted-model\")),\n (\n \"messages\".to_string(),\n json!([{\"role\":\"user\",\"content\":\"safe\"}]),\n ),\n (\"temperature\".to_string(), json!(0.2)),\n (\"max_tokens\".to_string(), json!(1024)),\n (\"response_format\".to_string(), json!({\"type\":\"json_object\"})),\n ]);\n\n merge_extra_body(\n &mut body,\n Some(r#\"{\"model\":\"attacker\",\"messages\":[],\"stream\":true,\"temperature\":2,\"max_tokens\":999999,\"max_completion_tokens\":999999,\"response_format\":{\"type\":\"text\"},\"thinking\":{\"type\":\"enabled\"},\"reasoning_effort\":\"high\",\"safe_extension\":true}\"#),\n \"\",\n )\n .expect(\"merge safe extension fields\");\n\n assert_eq!(body[\"model\"], \"trusted-model\");\n assert_eq!(body[\"messages\"][0][\"content\"], \"safe\");\n assert_eq!(body[\"temperature\"], 0.2);\n assert_eq!(body[\"max_tokens\"], 1024);\n assert_eq!(body[\"response_format\"][\"type\"], \"json_object\");\n for key in [\n \"stream\",\n \"max_completion_tokens\",\n \"thinking\",\n \"reasoning_effort\",\n ] {\n assert!(!body.contains_key(key), \"{key}\");\n }\n assert_eq!(body[\"safe_extension\"], true);\n }\n\n #[test]\n fn parse_openai_content_reads_normal_string_content() {\n let text =\n r#\"{\"choices\":[{\"finish_reason\":\"stop\",\"message\":{\"content\":\"{\\\"ok\\\":true}\"}}]}\"#;\n let content = parse_openai_content(text).expect(\"parse content\");\n assert_eq!(content, r#\"{\"ok\":true}\"#);\n }\n\n #[test]\n fn parse_openai_content_reports_empty_content() {\n let text = r#\"{\"choices\":[{\"finish_reason\":\"stop\",\"message\":{\"content\":\" \"}}]}\"#;\n let error = parse_openai_content(text).expect_err(\"empty content should fail\");\n assert!(error.contains(\"AI provider returned empty message.content.\"));\n assert!(error.contains(\"has_choices=true\"));\n assert!(error.contains(\"content_type=string\"));\n assert!(error.contains(\"content_length=2\"));\n }\n\n #[test]\n fn parse_openai_content_reports_reasoning_without_final_content() {\n let text = r#\"{\"choices\":[{\"finish_reason\":\"stop\",\"message\":{\"content\":\"\",\"reasoning_content\":\"thinking trace\"}}]}\"#;\n let error = parse_openai_content(text).expect_err(\"reasoning-only response should fail\");\n assert!(error.contains(\"AI provider returned reasoning_content but empty content.\"));\n assert!(error.contains(\"Please disable Thinking or use a non-thinking model.\"));\n assert!(error.contains(\"has_reasoning_content=true\"));\n assert!(error.contains(\"reasoning_content_length=14\"));\n }\n\n #[test]\n fn parse_openai_content_reads_array_text_content() {\n let text = r#\"{\"choices\":[{\"finish_reason\":\"stop\",\"message\":{\"content\":[{\"type\":\"text\",\"text\":\"{\\\"ok\\\":\"},{\"type\":\"text\",\"text\":\"true}\"}]}}]}\"#;\n let content = parse_openai_content(text).expect(\"parse text parts\");\n assert_eq!(content, r#\"{\"ok\":true}\"#);\n }\n\n #[test]\n fn parse_openai_content_reads_message_output_text() {\n let text =\n r#\"{\"choices\":[{\"finish_reason\":\"stop\",\"message\":{\"output_text\":\"{\\\"ok\\\":true}\"}}]}\"#;\n let content = parse_openai_content(text).expect(\"parse output_text\");\n assert_eq!(content, r#\"{\"ok\":true}\"#);\n }\n\n #[test]\n fn parse_openai_content_reads_message_text() {\n let text = r#\"{\"choices\":[{\"finish_reason\":\"stop\",\"message\":{\"text\":\"{\\\"ok\\\":true}\"}}]}\"#;\n let content = parse_openai_content(text).expect(\"parse text\");\n assert_eq!(content, r#\"{\"ok\":true}\"#);\n }\n\n #[test]\n fn model_discovery_parses_openai_data_and_deduplicates_ids() {\n let models = parse_model_list(\n r#\"{\"data\":[{\"id\":\"deepseek-v4-flash\",\"owned_by\":\"deepseek\"},{\"id\":\"deepseek-v4-flash\"},{\"id\":\"\"}]}\"#,\n )\n .expect(\"model list\");\n assert_eq!(models.len(), 1);\n assert_eq!(models[0].id, \"deepseek-v4-flash\");\n assert_eq!(models[0].owned_by.as_deref(), Some(\"deepseek\"));\n }\n\n #[test]\n fn deepseek_force_json_uses_json_object_response_format() {\n let provider = OpenAICompatibleProvider::new(AISettings::default());\n let (_, used_response_format, thinking) = provider\n .build_chat_body(&AIChatRequest {\n messages: vec![AIChatMessage {\n role: \"user\".to_string(),\n content: \"Return JSON.\".to_string(),\n }],\n model: \"deepseek-v4-flash\".to_string(),\n temperature: 0.0,\n max_tokens: 8192,\n force_json: true,\n provider_options: AIProviderOptions::default(),\n })\n .expect(\"build DeepSeek body\");\n let (body, _, _) = provider\n .build_chat_body(&AIChatRequest {\n messages: vec![AIChatMessage {\n role: \"user\".to_string(),\n content: \"Return JSON.\".to_string(),\n }],\n model: \"deepseek-v4-flash\".to_string(),\n temperature: 0.0,\n max_tokens: 8192,\n force_json: true,\n provider_options: AIProviderOptions::default(),\n })\n .expect(\"build DeepSeek body\");\n assert!(used_response_format);\n assert_eq!(body[\"response_format\"][\"type\"], \"json_object\");\n assert_eq!(thinking.as_deref(), Some(\"disabled\"));\n }\n\n #[test]\n fn json_only_retry_keeps_response_format_and_disables_thinking() {\n let mut settings = AISettings::default();\n settings.enable_thinking = true;\n let provider = OpenAICompatibleProvider::new(settings);\n let (body, used_response_format, thinking) = provider\n .build_chat_body(&AIChatRequest {\n messages: Vec::new(),\n model: \"deepseek-v4-flash\".to_string(),\n temperature: 0.0,\n max_tokens: 512,\n force_json: true,\n provider_options: AIProviderOptions {\n enable_thinking: Some(false),\n use_response_format: Some(true),\n ..Default::default()\n },\n })\n .expect(\"build retry body\");\n assert!(used_response_format);\n assert_eq!(body[\"response_format\"][\"type\"], \"json_object\");\n assert_eq!(body[\"thinking\"][\"type\"], \"disabled\");\n assert_eq!(thinking.as_deref(), Some(\"disabled\"));\n }\n\n #[test]\n fn registry_profiles_select_provider_specific_payload_fields() {\n let mut minimax_settings = AISettings::default();\n minimax_settings.preset = AIProviderPresetId::Minimax;\n minimax_settings.base_url = \"https://api.minimaxi.com/v1\".to_string();\n minimax_settings.enable_thinking = true;\n let minimax = OpenAICompatibleProvider::new(minimax_settings);\n let (body, _, _) = minimax\n .build_chat_body(&AIChatRequest {\n messages: Vec::new(),\n model: \"MiniMax-M2.5\".to_string(),\n temperature: 1.5,\n max_tokens: 100,\n force_json: true,\n provider_options: AIProviderOptions::default(),\n })\n .expect(\"MiniMax body\");\n assert_eq!(body[\"max_completion_tokens\"], 100);\n assert_eq!(body[\"reasoning_split\"], true);\n assert_eq!(body[\"temperature\"], 1.0);\n\n let mut qwen_settings = AISettings::default();\n qwen_settings.preset = AIProviderPresetId::QwenDashScope;\n qwen_settings.base_url = \"https://dashscope.aliyuncs.com/compatible-mode/v1\".to_string();\n qwen_settings.enable_thinking = true;\n let qwen = OpenAICompatibleProvider::new(qwen_settings);\n let (body, _, _) = qwen\n .build_chat_body(&AIChatRequest {\n messages: Vec::new(),\n model: \"qwen-plus\".to_string(),\n temperature: 0.2,\n max_tokens: 100,\n force_json: true,\n provider_options: AIProviderOptions::default(),\n })\n .expect(\"Qwen body\");\n assert_eq!(body[\"response_format\"][\"type\"], \"json_object\");\n assert_eq!(body[\"enable_thinking\"], true);\n }\n\n #[test]\n fn finish_reason_length_is_reported_as_truncated_json() {\n let error = parse_openai_content(\n r#\"{\"choices\":[{\"finish_reason\":\"length\",\"message\":{\"content\":\"{\\\"classifications\\\":[\"}}]}\"#,\n )\n .expect_err(\"length-limited output must fail\");\n assert!(error.contains(\"模型输出达到长度上限,JSON 被截断。\"));\n assert!(error.contains(\"finish_reason=length\"));\n }\n\n #[test]\n fn empty_json_content_is_explicitly_retryable() {\n let error = parse_openai_content(\n r#\"{\"choices\":[{\"finish_reason\":\"stop\",\"message\":{\"content\":\"\"}}]}\"#,\n )\n .expect_err(\"empty content must fail\");\n assert!(error.contains(\"AI provider returned empty message.content.\"));\n assert!(error.contains(\"JSON content is empty and should be retried\"));\n }\n\n #[test]\n fn parse_openai_content_includes_short_non_json_preview() {\n let text = r#\"{\"choices\":[{\"finish_reason\":\"stop\",\"message\":{\"content\":\"Sure, I can classify that file.\"}}]}\"#;\n let error = parse_openai_content(text).expect_err(\"non-json content should fail\");\n assert!(error.contains(\"AI provider returned non-JSON message content.\"));\n assert!(error.contains(\"content_preview=\\\"Sure, I can classify that file.\\\"\"));\n assert!(error.contains(\"message_keys=[content]\"));\n }\n\n #[test]\n fn provider_error_redaction_removes_api_key_from_summary() {\n let api_key = \"sk-provider-secret\";\n let text = r#\"{\"choices\":[{\"finish_reason\":\"stop\",\"message\":{\"content\":\"Provider rejected sk-provider-secret\"}}]}\"#;\n let error = parse_openai_content(text).expect_err(\"non-json content should fail\");\n let redacted = redact_api_key(&error, api_key);\n assert!(!redacted.contains(api_key));\n assert!(redacted.contains(\"[redacted]\"));\n }\n\n #[test]\n fn provider_rejects_redirects_without_forwarding_credentials() {\n let listener = TcpListener::bind(\"127.0.0.1:0\").expect(\"bind redirect fixture\");\n let address = listener.local_addr().expect(\"redirect address\");\n let server = thread::spawn(move || {\n let (mut stream, _) = listener.accept().expect(\"accept redirect request\");\n let mut request = [0_u8; 4096];\n let size = stream.read(&mut request).expect(\"read redirect request\");\n let request = String::from_utf8_lossy(&request[..size]);\n assert!(request\n .to_ascii_lowercase()\n .contains(\"authorization: bearer sk-provider-secret\"));\n stream\n .write_all(\n b\"HTTP/1.1 307 Temporary Redirect\\r\\nLocation: http://127.0.0.1:9/second\\r\\nContent-Length: 0\\r\\n\\r\\n\",\n )\n .expect(\"write redirect response\");\n });\n\n let settings = AISettings {\n base_url: format!(\"http://{address}\"),\n api_key: \"sk-provider-secret\".to_string(),\n ..AISettings::default()\n };\n let provider = OpenAICompatibleProvider::new(settings);\n let error = provider\n .send_chat_request_raw(AIChatRequest {\n messages: vec![AIChatMessage {\n role: \"user\".to_string(),\n content: \"hello\".to_string(),\n }],\n model: \"test-model\".to_string(),\n temperature: 0.0,\n max_tokens: 16,\n force_json: false,\n provider_options: AIProviderOptions::default(),\n })\n .expect_err(\"redirect must be rejected\");\n server.join().expect(\"redirect server\");\n let message = error.to_string();\n assert!(message.contains(\"redirect rejected\"));\n assert!(!message.contains(\"sk-provider-secret\"));\n }\n}\n"}, "eb76ca2a2719673b174a9bdaaeaddc7a401a49e5": {"t": 1785757168.5642307, "n": 27168, "h": "1d2cabdbdb21fffa", "c": "use std::time::Duration;\n\nuse reqwest::blocking::Client;\nuse serde_json::{json, Map, Value};\n\nuse super::{\n presets::{provider_preset, AIProviderPreset},\n provider::{AIProvider, AIProviderError},\n schema::{AIChatMessage, AIChatRequest, AIConnectionTestResult, AIProviderPresetId},\n settings::AISettings,\n};\n\ntype ChatBody = (Map<String, Value>, bool, Option<String>);\n\npub struct OpenAICompatibleProvider {\n settings: AISettings,\n preset: AIProviderPreset,\n client: Option<Client>,\n}\n\n#[derive(Debug, Clone)]\npub struct AIRawProviderResponse {\n pub status: u16,\n pub response_text: String,\n pub request_used_response_format: bool,\n pub request_used_thinking_field: Option<String>,\n pub response_summary: String,\n}\n\n#[derive(Debug, Clone, serde::Serialize)]\npub struct AIDebugExtractResult {\n pub finish_reason: Option<String>,\n pub message_keys: Vec<String>,\n pub message_content: Option<String>,\n pub reasoning_content: Option<String>,\n pub output_text: Option<String>,\n pub text: Option<String>,\n pub extracted_content: Option<String>,\n pub parse_error: Option<String>,\n}\n\nimpl OpenAICompatibleProvider {\n pub fn new(settings: AISettings) -> Self {\n let preset = provider_preset(settings.preset)\n .or_else(|| provider_preset(AIProviderPresetId::CustomOpenAICompatible))\n .expect(\"custom OpenAI-compatible preset exists\");\n let client = Client::builder()\n .timeout(Duration::from_secs(settings.timeout_seconds.max(1)))\n .redirect(reqwest::redirect::Policy::none())\n .build()\n .ok();\n Self {\n settings,\n preset,\n client,\n }\n }\n\n fn request_url(&self) -> Result<String, AIProviderError> {\n join_base_url_and_chat_path(&self.settings.base_url, &self.settings.chat_path)\n }\n\n fn client(&self) -> Result<&Client, AIProviderError> {\n self.client\n .as_ref()\n .ok_or_else(|| self.error(\"failed to build AI HTTP client\"))\n }\n\n fn error(&self, message: impl Into<String>) -> AIProviderError {\n AIProviderError::new(redact_api_key(&message.into(), &self.settings.api_key))\n }\n\n pub fn send_chat_request_raw(\n &self,\n request: AIChatRequest,\n ) -> Result<AIRawProviderResponse, AIProviderError> {\n let url = self.request_url()?;\n let (body, request_used_response_format, request_used_thinking_field) =\n self.build_chat_body(&request)?;\n\n let mut builder = self\n .client()?\n .post(url)\n .header(reqwest::header::CONTENT_TYPE, \"application/json\");\n if !self.settings.api_key.trim().is_empty() {\n builder = builder.bearer_auth(self.settings.api_key.trim());\n }\n\n let response = builder\n .json(&Value::Object(body))\n .send()\n .map_err(|error| self.error(format!(\"AI request failed: {error}\")))?;\n let status = response.status();\n if status.is_redirection() {\n return Err(self.error(format!(\n \"AI provider redirect rejected: HTTP {}\",\n status.as_u16()\n )));\n }\n let response_text = response\n .text()\n .map_err(|error| self.error(format!(\"failed to read AI response: {error}\")))?;\n let response_summary = serde_json::from_str::<Value>(&response_text)\n .map(|value| summarize_provider_response(&value))\n .unwrap_or_else(|error| format!(\"provider response summary: invalid_json={error}\"));\n\n Ok(AIRawProviderResponse {\n status: status.as_u16(),\n response_text: redact_api_key(&response_text, &self.settings.api_key),\n request_used_response_format,\n request_used_thinking_field,\n response_summary,\n })\n }\n\n fn build_chat_body(&self, request: &AIChatRequest) -> Result<ChatBody, AIProviderError> {\n let mut body = Map::new();\n body.insert(\"model\".to_string(), json!(request.model));\n body.insert(\n \"messages\".to_string(),\n json!(messages_with_instructions(\n &request.messages,\n request.force_json,\n thinking_enabled(&self.settings, request),\n self.preset.id\n )),\n );\n body.insert(\"temperature\".to_string(), json!(request.temperature));\n body.insert(\"max_tokens\".to_string(), json!(request.max_tokens));\n\n let response_format_enabled = request\n .provider_options\n .use_response_format\n .unwrap_or(self.preset.supports_response_format);\n if request.force_json && response_format_enabled {\n body.insert(\n \"response_format\".to_string(),\n json!({ \"type\": \"json_object\" }),\n );\n }\n\n // Advanced fields may extend the request, but core request semantics stay authoritative.\n merge_extra_body(\n &mut body,\n self.settings.extra_body_json.as_deref(),\n &self.settings.api_key,\n )\n .map_err(|error| self.error(error.to_string()))?;\n merge_extra_body(\n &mut body,\n request.provider_options.extra_body_json.as_deref(),\n &self.settings.api_key,\n )\n .map_err(|error| self.error(error.to_string()))?;\n\n let enable_thinking = thinking_enabled(&self.settings, request);\n if enable_thinking && self.preset.supports_reasoning_effort {\n if let Some(reasoning_effort) = request\n .provider_options\n .reasoning_effort\n .as_deref()\n .or(self.settings.reasoning_effort.as_deref())\n .filter(|value| !value.trim().is_empty())\n {\n body.insert(\"reasoning_effort\".to_string(), json!(reasoning_effort));\n }\n }\n if self.preset.id == AIProviderPresetId::DeepSeek\n && self.preset.supports_thinking\n && !body.contains_key(\"thinking\")\n {\n body.insert(\n \"thinking\".to_string(),\n json!({ \"type\": if enable_thinking { \"enabled\" } else { \"disabled\" } }),\n );\n }\n\n let request_used_thinking_field = body.get(\"thinking\").map(thinking_field_text);\n Ok((\n body,\n request.force_json && response_format_enabled,\n request_used_thinking_field,\n ))\n }\n}\n\nimpl AIProvider for OpenAICompatibleProvider {\n fn chat_json(&self, request: AIChatRequest) -> Result<String, AIProviderError> {\n let raw = self.send_chat_request_raw(request.clone())?;\n if !(200..300).contains(&raw.status) {\n let mut message = format!(\n \"AI provider returned HTTP {}: {}\",\n raw.status,\n short_response(&raw.response_text)\n );\n if self.preset.id == AIProviderPresetId::DeepSeek\n && !thinking_enabled(&self.settings, &request)\n {\n message.push_str(\n \" If DeepSeek rejects thinking disabled, use a non-thinking model or a compatible legacy model name.\",\n );\n }\n return Err(self.error(message));\n }\n\n parse_openai_content(&raw.response_text).map_err(|error| self.error(error))\n }\n\n fn test_connection(&self) -> Result<AIConnectionTestResult, AIProviderError> {\n let mut prompt = \"Return exactly this JSON and nothing else: {\\\"ok\\\":true}\\nDo not output Markdown.\\nDo not output reasoning.\\nDo not output <think>.\\nDo not explain.\".to_string();\n if self.preset.id == AIProviderPresetId::DeepSeek && !self.settings.enable_thinking {\n prompt.push_str(\"\\nUse non-thinking mode and only return final content.\");\n }\n let test_max_tokens = self.settings.max_tokens.clamp(512, 4096);\n let content = self.chat_json(AIChatRequest {\n messages: vec![AIChatMessage {\n role: \"user\".to_string(),\n content: prompt,\n }],\n model: self.settings.model.clone(),\n temperature: 0.0,\n max_tokens: test_max_tokens,\n force_json: true,\n provider_options: Default::default(),\n })?;\n Ok(AIConnectionTestResult {\n ok: true,\n message: format!(\"AI provider responded: {}\", short_response(&content)),\n model: Some(self.settings.model.clone()),\n provider: Some(self.settings.provider),\n preset: Some(self.settings.preset),\n elapsed_ms: 0,\n })\n }\n}\n\npub fn join_base_url_and_chat_path(\n base_url: &str,\n chat_path: &str,\n) -> Result<String, AIProviderError> {\n let base = base_url.trim().trim_end_matches('/');\n let path = chat_path.trim().trim_start_matches('/');\n if base.is_empty() {\n return Err(AIProviderError::new(\"AI provider base_url is empty.\"));\n }\n if path.is_empty() {\n return Err(AIProviderError::new(\"AI provider chat_path is empty.\"));\n }\n Ok(format!(\"{base}/{path}\"))\n}\n\nfn messages_with_instructions(\n messages: &[AIChatMessage],\n force_json: bool,\n enable_thinking: bool,\n preset_id: AIProviderPresetId,\n) -> Vec<AIChatMessage> {\n let mut output = Vec::new();\n let mut instructions = Vec::new();\n if force_json {\n instructions.push(\"Return only valid JSON. Do not wrap the JSON in markdown.\".to_string());\n }\n if !enable_thinking {\n instructions.push(\"Do not output thinking, reasoning traces, or explanations.\".to_string());\n }\n if preset_id == AIProviderPresetId::DeepSeek {\n instructions.push(\n \"DeepSeek legacy model names deepseek-chat and deepseek-reasoner may be deprecated; keep output compatible with the selected model.\".to_string(),\n );\n }\n if !instructions.is_empty() {\n output.push(AIChatMessage {\n role: \"system\".to_string(),\n content: instructions.join(\" \"),\n });\n }\n output.extend_from_slice(messages);\n output\n}\n\nfn thinking_enabled(settings: &AISettings, request: &AIChatRequest) -> bool {\n request\n .provider_options\n .enable_thinking\n .unwrap_or(settings.enable_thinking)\n}\n\nfn merge_extra_body(\n body: &mut Map<String, Value>,\n extra_body_json: Option<&str>,\n api_key: &str,\n) -> Result<(), AIProviderError> {\n let Some(extra_body_json) = extra_body_json.filter(|value| !value.trim().is_empty()) else {\n return Ok(());\n };\n let value = serde_json::from_str::<Value>(extra_body_json).map_err(|error| {\n AIProviderError::new(redact_api_key(\n &format!(\"AI extra_body_json must be a JSON object: {error}\"),\n api_key,\n ))\n })?;\n let Value::Object(extra) = value else {\n return Err(AIProviderError::new(\n \"AI extra_body_json must be a JSON object.\",\n ));\n };\n for (key, value) in extra {\n if matches!(\n key.as_str(),\n \"model\"\n | \"messages\"\n | \"stream\"\n | \"temperature\"\n | \"max_tokens\"\n | \"max_completion_tokens\"\n | \"response_format\"\n | \"thinking\"\n | \"reasoning_effort\"\n ) {\n continue;\n }\n body.insert(key, value);\n }\n Ok(())\n}\n\nfn parse_openai_content(text: &str) -> Result<String, String> {\n let value = serde_json::from_str::<Value>(text)\n .map_err(|error| format!(\"AI provider returned invalid JSON: {error}\"))?;\n let summary = summarize_provider_response(&value);\n let Some(choice) = value\n .get(\"choices\")\n .and_then(Value::as_array)\n .and_then(|choices| choices.first())\n else {\n return Err(format!(\n \"AI provider response did not contain choices[0]. {summary}\"\n ));\n };\n let Some(message) = choice.get(\"message\").and_then(Value::as_object) else {\n return Err(format!(\n \"AI provider response did not contain choices[0].message. {summary}\"\n ));\n };\n\n if let Some(content) = message.get(\"content\") {\n let content = content_value_to_text(content).ok_or_else(|| {\n format!(\"AI provider response had unsupported message.content shape. {summary}\")\n })?;\n if content.trim().is_empty() {\n if value_text_len(message.get(\"reasoning_content\")) > 0 {\n if choice.get(\"finish_reason\").and_then(Value::as_str) == Some(\"length\") {\n return Err(format!(\n \"AI provider returned reasoning_content but empty content. 模型只返回了 reasoning_content,且因为输出长度限制被截断,没有生成最终 content。请关闭 Thinking,并提高连接测试 max_tokens;Zen Canvas 已不再使用 64 token 进行连接测试。 {summary}\"\n ));\n }\n return Err(format!(\n \"AI provider returned reasoning_content but empty content. Please disable Thinking or use a non-thinking model. {summary}\"\n ));\n }\n return Err(format!(\n \"AI provider returned empty message.content. {summary}\"\n ));\n }\n return validate_content_has_jsonish_text(content, &summary);\n }\n\n for fallback_key in [\"output_text\", \"text\"] {\n if let Some(content) = message.get(fallback_key) {\n let content = content_value_to_text(content).ok_or_else(|| {\n format!(\n \"AI provider response had unsupported message.{fallback_key} shape. {summary}\"\n )\n })?;\n if content.trim().is_empty() {\n continue;\n }\n return validate_content_has_jsonish_text(content, &summary);\n }\n }\n\n Err(format!(\n \"AI provider response did not contain message.content, message.output_text, or message.text. {summary}\"\n ))\n}\n\npub fn debug_extract_openai_response(raw_response: &str) -> AIDebugExtractResult {\n let value = match serde_json::from_str::<Value>(raw_response) {\n Ok(value) => value,\n Err(error) => {\n return AIDebugExtractResult {\n finish_reason: None,\n message_keys: Vec::new(),\n message_content: None,\n reasoning_content: None,\n output_text: None,\n text: None,\n extracted_content: None,\n parse_error: Some(format!(\"provider raw response is not JSON: {error}\")),\n };\n }\n };\n let choice = value\n .get(\"choices\")\n .and_then(Value::as_array)\n .and_then(|choices| choices.first());\n let message = choice\n .and_then(|choice| choice.get(\"message\"))\n .and_then(Value::as_object);\n let mut message_keys = message\n .map(|message| message.keys().cloned().collect::<Vec<_>>())\n .unwrap_or_default();\n message_keys.sort();\n let message_content = message\n .and_then(|message| message.get(\"content\"))\n .and_then(content_value_to_text);\n let output_text = message\n .and_then(|message| message.get(\"output_text\"))\n .and_then(content_value_to_text);\n let text = message\n .and_then(|message| message.get(\"text\"))\n .and_then(content_value_to_text);\n let extracted_content = message_content\n .clone()\n .or_else(|| output_text.clone())\n .or_else(|| text.clone());\n\n AIDebugExtractResult {\n finish_reason: choice\n .and_then(|choice| choice.get(\"finish_reason\"))\n .and_then(Value::as_str)\n .map(ToString::to_string),\n message_keys,\n message_content,\n reasoning_content: message\n .and_then(|message| message.get(\"reasoning_content\"))\n .and_then(content_value_to_text),\n output_text,\n text,\n extracted_content,\n parse_error: message\n .is_none()\n .then(|| \"provider response did not contain choices[0].message\".to_string()),\n }\n}\n\npub(crate) fn summarize_provider_response(value: &Value) -> String {\n let choices = value.get(\"choices\").and_then(Value::as_array);\n let choice = choices.and_then(|items| items.first());\n let message = choice\n .and_then(|choice| choice.get(\"message\"))\n .and_then(Value::as_object);\n let mut message_keys = message\n .map(|message| message.keys().cloned().collect::<Vec<_>>())\n .unwrap_or_default();\n message_keys.sort();\n let content = message.and_then(|message| message.get(\"content\"));\n let reasoning_content = message.and_then(|message| message.get(\"reasoning_content\"));\n format!(\n \"provider response summary: has_choices={}; choice_count={}; finish_reason={}; message_keys={}; content_type={}; content_length={}; has_reasoning_content={}; reasoning_content_length={}\",\n choices.is_some(),\n choices.map_or(0, Vec::len),\n choice\n .and_then(|choice| choice.get(\"finish_reason\"))\n .map(json_type_or_string)\n .unwrap_or_else(|| \"missing\".to_string()),\n if message_keys.is_empty() {\n \"[]\".to_string()\n } else {\n format!(\"[{}]\", message_keys.join(\",\"))\n },\n content.map(json_type_name).unwrap_or(\"missing\"),\n value_text_len(content),\n reasoning_content.is_some(),\n value_text_len(reasoning_content),\n )\n}\n\nfn thinking_field_text(value: &Value) -> String {\n value\n .get(\"type\")\n .and_then(Value::as_str)\n .map(ToString::to_string)\n .unwrap_or_else(|| {\n serde_json::to_string(value).unwrap_or_else(|_| json_type_name(value).to_string())\n })\n}\n\nfn content_value_to_text(value: &Value) -> Option<String> {\n match value {\n Value::String(text) => Some(text.clone()),\n Value::Array(items) => {\n let mut output = String::new();\n for item in items {\n let Some(object) = item.as_object() else {\n continue;\n };\n if object.get(\"type\").and_then(Value::as_str) == Some(\"text\") {\n if let Some(text) = object.get(\"text\").and_then(Value::as_str) {\n output.push_str(text);\n }\n }\n }\n Some(output)\n }\n _ => None,\n }\n}\n\nfn validate_content_has_jsonish_text(content: String, summary: &str) -> Result<String, String> {\n if content.contains('{') || content.contains('[') {\n Ok(content)\n } else {\n Err(format!(\n \"AI provider returned non-JSON message content. content_preview=\\\"{}\\\". {summary}\",\n preview_text(&content, 300)\n ))\n }\n}\n\nfn value_text_len(value: Option<&Value>) -> usize {\n value\n .and_then(content_value_to_text)\n .map(|text| text.chars().count())\n .unwrap_or(0)\n}\n\nfn json_type_or_string(value: &Value) -> String {\n value\n .as_str()\n .map(ToString::to_string)\n .unwrap_or_else(|| json_type_name(value).to_string())\n}\n\nfn json_type_name(value: &Value) -> &'static str {\n match value {\n Value::Null => \"null\",\n Value::Bool(_) => \"boolean\",\n Value::Number(_) => \"number\",\n Value::String(_) => \"string\",\n Value::Array(_) => \"array\",\n Value::Object(_) => \"object\",\n }\n}\n\nfn preview_text(text: &str, limit: usize) -> String {\n let mut preview = text\n .chars()\n .take(limit)\n .collect::<String>()\n .replace(['\\r', '\\n', '\\t'], \" \");\n if text.chars().count() > limit {\n preview.push_str(\"...\");\n }\n preview\n}\n\nfn short_response(text: &str) -> String {\n const LIMIT: usize = 500;\n if text.len() <= LIMIT {\n text.to_string()\n } else {\n format!(\"{}...\", &text[..LIMIT])\n }\n}\n\nfn redact_api_key(message: &str, api_key: &str) -> String {\n let trimmed = api_key.trim();\n if trimmed.is_empty() {\n message.to_string()\n } else {\n message.replace(trimmed, \"[redacted]\")\n }\n}\n\n#[cfg(test)]\nmod tests {\n use super::*;\n use crate::ai::schema::AIProviderOptions;\n use std::{\n io::{Read, Write},\n net::TcpListener,\n thread,\n };\n\n #[test]\n fn extra_body_cannot_override_reserved_fields() {\n let mut body = Map::from_iter([\n (\"model\".to_string(), json!(\"trusted-model\")),\n (\n \"messages\".to_string(),\n json!([{\"role\":\"user\",\"content\":\"safe\"}]),\n ),\n (\"temperature\".to_string(), json!(0.2)),\n (\"max_tokens\".to_string(), json!(1024)),\n (\"response_format\".to_string(), json!({\"type\":\"json_object\"})),\n ]);\n\n merge_extra_body(\n &mut body,\n Some(r#\"{\"model\":\"attacker\",\"messages\":[],\"stream\":true,\"temperature\":2,\"max_tokens\":999999,\"max_completion_tokens\":999999,\"response_format\":{\"type\":\"text\"},\"thinking\":{\"type\":\"enabled\"},\"reasoning_effort\":\"high\",\"safe_extension\":true}\"#),\n \"\",\n )\n .expect(\"merge safe extension fields\");\n\n assert_eq!(body[\"model\"], \"trusted-model\");\n assert_eq!(body[\"messages\"][0][\"content\"], \"safe\");\n assert_eq!(body[\"temperature\"], 0.2);\n assert_eq!(body[\"max_tokens\"], 1024);\n assert_eq!(body[\"response_format\"][\"type\"], \"json_object\");\n for key in [\n \"stream\",\n \"max_completion_tokens\",\n \"thinking\",\n \"reasoning_effort\",\n ] {\n assert!(!body.contains_key(key), \"{key}\");\n }\n assert_eq!(body[\"safe_extension\"], true);\n }\n\n #[test]\n fn parse_openai_content_reads_normal_string_content() {\n let text =\n r#\"{\"choices\":[{\"finish_reason\":\"stop\",\"message\":{\"content\":\"{\\\"ok\\\":true}\"}}]}\"#;\n let content = parse_openai_content(text).expect(\"parse content\");\n assert_eq!(content, r#\"{\"ok\":true}\"#);\n }\n\n #[test]\n fn parse_openai_content_reports_empty_content() {\n let text = r#\"{\"choices\":[{\"finish_reason\":\"stop\",\"message\":{\"content\":\" \"}}]}\"#;\n let error = parse_openai_content(text).expect_err(\"empty content should fail\");\n assert!(error.contains(\"AI provider returned empty message.content.\"));\n assert!(error.contains(\"has_choices=true\"));\n assert!(error.contains(\"content_type=string\"));\n assert!(error.contains(\"content_length=2\"));\n }\n\n #[test]\n fn parse_openai_content_reports_reasoning_without_final_content() {\n let text = r#\"{\"choices\":[{\"finish_reason\":\"stop\",\"message\":{\"content\":\"\",\"reasoning_content\":\"thinking trace\"}}]}\"#;\n let error = parse_openai_content(text).expect_err(\"reasoning-only response should fail\");\n assert!(error.contains(\"AI provider returned reasoning_content but empty content.\"));\n assert!(error.contains(\"Please disable Thinking or use a non-thinking model.\"));\n assert!(error.contains(\"has_reasoning_content=true\"));\n assert!(error.contains(\"reasoning_content_length=14\"));\n }\n\n #[test]\n fn parse_openai_content_reads_array_text_content() {\n let text = r#\"{\"choices\":[{\"finish_reason\":\"stop\",\"message\":{\"content\":[{\"type\":\"text\",\"text\":\"{\\\"ok\\\":\"},{\"type\":\"text\",\"text\":\"true}\"}]}}]}\"#;\n let content = parse_openai_content(text).expect(\"parse text parts\");\n assert_eq!(content, r#\"{\"ok\":true}\"#);\n }\n\n #[test]\n fn parse_openai_content_reads_message_output_text() {\n let text =\n r#\"{\"choices\":[{\"finish_reason\":\"stop\",\"message\":{\"output_text\":\"{\\\"ok\\\":true}\"}}]}\"#;\n let content = parse_openai_content(text).expect(\"parse output_text\");\n assert_eq!(content, r#\"{\"ok\":true}\"#);\n }\n\n #[test]\n fn parse_openai_content_reads_message_text() {\n let text = r#\"{\"choices\":[{\"finish_reason\":\"stop\",\"message\":{\"text\":\"{\\\"ok\\\":true}\"}}]}\"#;\n let content = parse_openai_content(text).expect(\"parse text\");\n assert_eq!(content, r#\"{\"ok\":true}\"#);\n }\n\n #[test]\n fn parse_openai_content_includes_short_non_json_preview() {\n let text = r#\"{\"choices\":[{\"finish_reason\":\"stop\",\"message\":{\"content\":\"Sure, I can classify that file.\"}}]}\"#;\n let error = parse_openai_content(text).expect_err(\"non-json content should fail\");\n assert!(error.contains(\"AI provider returned non-JSON message content.\"));\n assert!(error.contains(\"content_preview=\\\"Sure, I can classify that file.\\\"\"));\n assert!(error.contains(\"message_keys=[content]\"));\n }\n\n #[test]\n fn provider_error_redaction_removes_api_key_from_summary() {\n let api_key = \"sk-provider-secret\";\n let text = r#\"{\"choices\":[{\"finish_reason\":\"stop\",\"message\":{\"content\":\"Provider rejected sk-provider-secret\"}}]}\"#;\n let error = parse_openai_content(text).expect_err(\"non-json content should fail\");\n let redacted = redact_api_key(&error, api_key);\n assert!(!redacted.contains(api_key));\n assert!(redacted.contains(\"[redacted]\"));\n }\n\n #[test]\n fn provider_rejects_redirects_without_forwarding_credentials() {\n let listener = TcpListener::bind(\"127.0.0.1:0\").expect(\"bind redirect fixture\");\n let address = listener.local_addr().expect(\"redirect address\");\n let server = thread::spawn(move || {\n let (mut stream, _) = listener.accept().expect(\"accept redirect request\");\n let mut request = [0_u8; 4096];\n let size = stream.read(&mut request).expect(\"read redirect request\");\n let request = String::from_utf8_lossy(&request[..size]);\n assert!(request\n .to_ascii_lowercase()\n .contains(\"authorization: bearer sk-provider-secret\"));\n stream\n .write_all(\n b\"HTTP/1.1 307 Temporary Redirect\\r\\nLocation: http://127.0.0.1:9/second\\r\\nContent-Length: 0\\r\\n\\r\\n\",\n )\n .expect(\"write redirect response\");\n });\n\n let settings = AISettings {\n base_url: format!(\"http://{address}\"),\n api_key: \"sk-provider-secret\".to_string(),\n ..AISettings::default()\n };\n let provider = OpenAICompatibleProvider::new(settings);\n let error = provider\n .send_chat_request_raw(AIChatRequest {\n messages: vec![AIChatMessage {\n role: \"user\".to_string(),\n content: \"hello\".to_string(),\n }],\n model: \"test-model\".to_string(),\n temperature: 0.0,\n max_tokens: 16,\n force_json: false,\n provider_options: AIProviderOptions::default(),\n })\n .expect_err(\"redirect must be rejected\");\n server.join().expect(\"redirect server\");\n let message = error.to_string();\n assert!(message.contains(\"redirect rejected\"));\n assert!(!message.contains(\"sk-provider-secret\"));\n }\n}\n"}, "11f60630ca771e0b2f1701b0bf5a1387ec03234d": {"t": 1785757170.4499772, "n": 24826, "h": "abdbc6b841d5148a", "c": "use std::time::Duration;\n\nuse reqwest::blocking::Client;\nuse serde_json::{json, Map, Value};\n\nuse super::{\n presets::{provider_preset, AIProviderPreset},\n provider::{AIProvider, AIProviderError},\n schema::{AIChatMessage, AIChatRequest, AIConnectionTestResult, AIProviderPresetId},\n settings::AISettings,\n};\n\ntype ChatBody = (Map<String, Value>, bool, Option<String>);\n\npub struct OpenAICompatibleProvider {\n settings: AISettings,\n preset: AIProviderPreset,\n client: Option<Client>,\n}\n\n#[derive(Debug, Clone)]\npub struct AIRawProviderResponse {\n pub status: u16,\n pub response_text: String,\n pub request_used_response_format: bool,\n pub request_used_thinking_field: Option<String>,\n pub response_summary: String,\n}\n\n#[derive(Debug, Clone, serde::Serialize)]\npub struct AIDebugExtractResult {\n pub finish_reason: Option<String>,\n pub message_keys: Vec<String>,\n pub message_content: Option<String>,\n pub reasoning_content: Option<String>,\n pub output_text: Option<String>,\n pub text: Option<String>,\n pub extracted_content: Option<String>,\n pub parse_error: Option<String>,\n}\n\nimpl OpenAICompatibleProvider {\n pub fn new(settings: AISettings) -> Self {\n let preset = provider_preset(settings.preset)\n .or_else(|| provider_preset(AIProviderPresetId::CustomOpenAICompatible))\n .expect(\"custom OpenAI-compatible preset exists\");\n let client = Client::builder()\n .timeout(Duration::from_secs(settings.timeout_seconds.max(1)))\n .build()\n .ok();\n Self {\n settings,\n preset,\n client,\n }\n }\n\n fn request_url(&self) -> Result<String, AIProviderError> {\n join_base_url_and_chat_path(&self.settings.base_url, &self.settings.chat_path)\n }\n\n fn client(&self) -> Result<&Client, AIProviderError> {\n self.client\n .as_ref()\n .ok_or_else(|| self.error(\"failed to build AI HTTP client\"))\n }\n\n fn error(&self, message: impl Into<String>) -> AIProviderError {\n AIProviderError::new(redact_api_key(&message.into(), &self.settings.api_key))\n }\n\n pub fn send_chat_request_raw(\n &self,\n request: AIChatRequest,\n ) -> Result<AIRawProviderResponse, AIProviderError> {\n let url = self.request_url()?;\n let (body, request_used_response_format, request_used_thinking_field) =\n self.build_chat_body(&request)?;\n\n let mut builder = self\n .client()?\n .post(url)\n .header(reqwest::header::CONTENT_TYPE, \"application/json\");\n if !self.settings.api_key.trim().is_empty() {\n builder = builder.bearer_auth(self.settings.api_key.trim());\n }\n\n let response = builder\n .json(&Value::Object(body))\n .send()\n .map_err(|error| self.error(format!(\"AI request failed: {error}\")))?;\n let status = response.status();\n let response_text = response\n .text()\n .map_err(|error| self.error(format!(\"failed to read AI response: {error}\")))?;\n let response_summary = serde_json::from_str::<Value>(&response_text)\n .map(|value| summarize_provider_response(&value))\n .unwrap_or_else(|error| format!(\"provider response summary: invalid_json={error}\"));\n\n Ok(AIRawProviderResponse {\n status: status.as_u16(),\n response_text: redact_api_key(&response_text, &self.settings.api_key),\n request_used_response_format,\n request_used_thinking_field,\n response_summary,\n })\n }\n\n fn build_chat_body(&self, request: &AIChatRequest) -> Result<ChatBody, AIProviderError> {\n let mut body = Map::new();\n body.insert(\"model\".to_string(), json!(request.model));\n body.insert(\n \"messages\".to_string(),\n json!(messages_with_instructions(\n &request.messages,\n request.force_json,\n thinking_enabled(&self.settings, request),\n self.preset.id\n )),\n );\n body.insert(\"temperature\".to_string(), json!(request.temperature));\n body.insert(\"max_tokens\".to_string(), json!(request.max_tokens));\n\n let response_format_enabled = request\n .provider_options\n .use_response_format\n .unwrap_or(self.preset.supports_response_format);\n if request.force_json && response_format_enabled {\n body.insert(\n \"response_format\".to_string(),\n json!({ \"type\": \"json_object\" }),\n );\n }\n\n // Advanced fields may extend the request, but core request semantics stay authoritative.\n merge_extra_body(\n &mut body,\n self.settings.extra_body_json.as_deref(),\n &self.settings.api_key,\n )\n .map_err(|error| self.error(error.to_string()))?;\n merge_extra_body(\n &mut body,\n request.provider_options.extra_body_json.as_deref(),\n &self.settings.api_key,\n )\n .map_err(|error| self.error(error.to_string()))?;\n\n let enable_thinking = thinking_enabled(&self.settings, request);\n if enable_thinking && self.preset.supports_reasoning_effort {\n if let Some(reasoning_effort) = request\n .provider_options\n .reasoning_effort\n .as_deref()\n .or(self.settings.reasoning_effort.as_deref())\n .filter(|value| !value.trim().is_empty())\n {\n body.insert(\"reasoning_effort\".to_string(), json!(reasoning_effort));\n }\n }\n if self.preset.id == AIProviderPresetId::DeepSeek\n && self.preset.supports_thinking\n && !body.contains_key(\"thinking\")\n {\n body.insert(\n \"thinking\".to_string(),\n json!({ \"type\": if enable_thinking { \"enabled\" } else { \"disabled\" } }),\n );\n }\n\n let request_used_thinking_field = body.get(\"thinking\").map(thinking_field_text);\n Ok((\n body,\n request.force_json && response_format_enabled,\n request_used_thinking_field,\n ))\n }\n}\n\nimpl AIProvider for OpenAICompatibleProvider {\n fn chat_json(&self, request: AIChatRequest) -> Result<String, AIProviderError> {\n let raw = self.send_chat_request_raw(request.clone())?;\n if !(200..300).contains(&raw.status) {\n let mut message = format!(\n \"AI provider returned HTTP {}: {}\",\n raw.status,\n short_response(&raw.response_text)\n );\n if self.preset.id == AIProviderPresetId::DeepSeek\n && !thinking_enabled(&self.settings, &request)\n {\n message.push_str(\n \" If DeepSeek rejects thinking disabled, use a non-thinking model or a compatible legacy model name.\",\n );\n }\n return Err(self.error(message));\n }\n\n parse_openai_content(&raw.response_text).map_err(|error| self.error(error))\n }\n\n fn test_connection(&self) -> Result<AIConnectionTestResult, AIProviderError> {\n let mut prompt = \"Return exactly this JSON and nothing else: {\\\"ok\\\":true}\\nDo not output Markdown.\\nDo not output reasoning.\\nDo not output <think>.\\nDo not explain.\".to_string();\n if self.preset.id == AIProviderPresetId::DeepSeek && !self.settings.enable_thinking {\n prompt.push_str(\"\\nUse non-thinking mode and only return final content.\");\n }\n let test_max_tokens = self.settings.max_tokens.clamp(512, 4096);\n let content = self.chat_json(AIChatRequest {\n messages: vec![AIChatMessage {\n role: \"user\".to_string(),\n content: prompt,\n }],\n model: self.settings.model.clone(),\n temperature: 0.0,\n max_tokens: test_max_tokens,\n force_json: true,\n provider_options: Default::default(),\n })?;\n Ok(AIConnectionTestResult {\n ok: true,\n message: format!(\"AI provider responded: {}\", short_response(&content)),\n model: Some(self.settings.model.clone()),\n provider: Some(self.settings.provider),\n preset: Some(self.settings.preset),\n elapsed_ms: 0,\n })\n }\n}\n\npub fn join_base_url_and_chat_path(\n base_url: &str,\n chat_path: &str,\n) -> Result<String, AIProviderError> {\n let base = base_url.trim().trim_end_matches('/');\n let path = chat_path.trim().trim_start_matches('/');\n if base.is_empty() {\n return Err(AIProviderError::new(\"AI provider base_url is empty.\"));\n }\n if path.is_empty() {\n return Err(AIProviderError::new(\"AI provider chat_path is empty.\"));\n }\n Ok(format!(\"{base}/{path}\"))\n}\n\nfn messages_with_instructions(\n messages: &[AIChatMessage],\n force_json: bool,\n enable_thinking: bool,\n preset_id: AIProviderPresetId,\n) -> Vec<AIChatMessage> {\n let mut output = Vec::new();\n let mut instructions = Vec::new();\n if force_json {\n instructions.push(\"Return only valid JSON. Do not wrap the JSON in markdown.\".to_string());\n }\n if !enable_thinking {\n instructions.push(\"Do not output thinking, reasoning traces, or explanations.\".to_string());\n }\n if preset_id == AIProviderPresetId::DeepSeek {\n instructions.push(\n \"DeepSeek legacy model names deepseek-chat and deepseek-reasoner may be deprecated; keep output compatible with the selected model.\".to_string(),\n );\n }\n if !instructions.is_empty() {\n output.push(AIChatMessage {\n role: \"system\".to_string(),\n content: instructions.join(\" \"),\n });\n }\n output.extend_from_slice(messages);\n output\n}\n\nfn thinking_enabled(settings: &AISettings, request: &AIChatRequest) -> bool {\n request\n .provider_options\n .enable_thinking\n .unwrap_or(settings.enable_thinking)\n}\n\nfn merge_extra_body(\n body: &mut Map<String, Value>,\n extra_body_json: Option<&str>,\n api_key: &str,\n) -> Result<(), AIProviderError> {\n let Some(extra_body_json) = extra_body_json.filter(|value| !value.trim().is_empty()) else {\n return Ok(());\n };\n let value = serde_json::from_str::<Value>(extra_body_json).map_err(|error| {\n AIProviderError::new(redact_api_key(\n &format!(\"AI extra_body_json must be a JSON object: {error}\"),\n api_key,\n ))\n })?;\n let Value::Object(extra) = value else {\n return Err(AIProviderError::new(\n \"AI extra_body_json must be a JSON object.\",\n ));\n };\n for (key, value) in extra {\n if matches!(\n key.as_str(),\n \"model\"\n | \"messages\"\n | \"stream\"\n | \"temperature\"\n | \"max_tokens\"\n | \"max_completion_tokens\"\n | \"response_format\"\n | \"thinking\"\n | \"reasoning_effort\"\n ) {\n continue;\n }\n body.insert(key, value);\n }\n Ok(())\n}\n\nfn parse_openai_content(text: &str) -> Result<String, String> {\n let value = serde_json::from_str::<Value>(text)\n .map_err(|error| format!(\"AI provider returned invalid JSON: {error}\"))?;\n let summary = summarize_provider_response(&value);\n let Some(choice) = value\n .get(\"choices\")\n .and_then(Value::as_array)\n .and_then(|choices| choices.first())\n else {\n return Err(format!(\n \"AI provider response did not contain choices[0]. {summary}\"\n ));\n };\n let Some(message) = choice.get(\"message\").and_then(Value::as_object) else {\n return Err(format!(\n \"AI provider response did not contain choices[0].message. {summary}\"\n ));\n };\n\n if let Some(content) = message.get(\"content\") {\n let content = content_value_to_text(content).ok_or_else(|| {\n format!(\"AI provider response had unsupported message.content shape. {summary}\")\n })?;\n if content.trim().is_empty() {\n if value_text_len(message.get(\"reasoning_content\")) > 0 {\n if choice.get(\"finish_reason\").and_then(Value::as_str) == Some(\"length\") {\n return Err(format!(\n \"AI provider returned reasoning_content but empty content. 模型只返回了 reasoning_content,且因为输出长度限制被截断,没有生成最终 content。请关闭 Thinking,并提高连接测试 max_tokens;Zen Canvas 已不再使用 64 token 进行连接测试。 {summary}\"\n ));\n }\n return Err(format!(\n \"AI provider returned reasoning_content but empty content. Please disable Thinking or use a non-thinking model. {summary}\"\n ));\n }\n return Err(format!(\n \"AI provider returned empty message.content. {summary}\"\n ));\n }\n return validate_content_has_jsonish_text(content, &summary);\n }\n\n for fallback_key in [\"output_text\", \"text\"] {\n if let Some(content) = message.get(fallback_key) {\n let content = content_value_to_text(content).ok_or_else(|| {\n format!(\n \"AI provider response had unsupported message.{fallback_key} shape. {summary}\"\n )\n })?;\n if content.trim().is_empty() {\n continue;\n }\n return validate_content_has_jsonish_text(content, &summary);\n }\n }\n\n Err(format!(\n \"AI provider response did not contain message.content, message.output_text, or message.text. {summary}\"\n ))\n}\n\npub fn debug_extract_openai_response(raw_response: &str) -> AIDebugExtractResult {\n let value = match serde_json::from_str::<Value>(raw_response) {\n Ok(value) => value,\n Err(error) => {\n return AIDebugExtractResult {\n finish_reason: None,\n message_keys: Vec::new(),\n message_content: None,\n reasoning_content: None,\n output_text: None,\n text: None,\n extracted_content: None,\n parse_error: Some(format!(\"provider raw response is not JSON: {error}\")),\n };\n }\n };\n let choice = value\n .get(\"choices\")\n .and_then(Value::as_array)\n .and_then(|choices| choices.first());\n let message = choice\n .and_then(|choice| choice.get(\"message\"))\n .and_then(Value::as_object);\n let mut message_keys = message\n .map(|message| message.keys().cloned().collect::<Vec<_>>())\n .unwrap_or_default();\n message_keys.sort();\n let message_content = message\n .and_then(|message| message.get(\"content\"))\n .and_then(content_value_to_text);\n let output_text = message\n .and_then(|message| message.get(\"output_text\"))\n .and_then(content_value_to_text);\n let text = message\n .and_then(|message| message.get(\"text\"))\n .and_then(content_value_to_text);\n let extracted_content = message_content\n .clone()\n .or_else(|| output_text.clone())\n .or_else(|| text.clone());\n\n AIDebugExtractResult {\n finish_reason: choice\n .and_then(|choice| choice.get(\"finish_reason\"))\n .and_then(Value::as_str)\n .map(ToString::to_string),\n message_keys,\n message_content,\n reasoning_content: message\n .and_then(|message| message.get(\"reasoning_content\"))\n .and_then(content_value_to_text),\n output_text,\n text,\n extracted_content,\n parse_error: message\n .is_none()\n .then(|| \"provider response did not contain choices[0].message\".to_string()),\n }\n}\n\npub(crate) fn summarize_provider_response(value: &Value) -> String {\n let choices = value.get(\"choices\").and_then(Value::as_array);\n let choice = choices.and_then(|items| items.first());\n let message = choice\n .and_then(|choice| choice.get(\"message\"))\n .and_then(Value::as_object);\n let mut message_keys = message\n .map(|message| message.keys().cloned().collect::<Vec<_>>())\n .unwrap_or_default();\n message_keys.sort();\n let content = message.and_then(|message| message.get(\"content\"));\n let reasoning_content = message.and_then(|message| message.get(\"reasoning_content\"));\n format!(\n \"provider response summary: has_choices={}; choice_count={}; finish_reason={}; message_keys={}; content_type={}; content_length={}; has_reasoning_content={}; reasoning_content_length={}\",\n choices.is_some(),\n choices.map_or(0, Vec::len),\n choice\n .and_then(|choice| choice.get(\"finish_reason\"))\n .map(json_type_or_string)\n .unwrap_or_else(|| \"missing\".to_string()),\n if message_keys.is_empty() {\n \"[]\".to_string()\n } else {\n format!(\"[{}]\", message_keys.join(\",\"))\n },\n content.map(json_type_name).unwrap_or(\"missing\"),\n value_text_len(content),\n reasoning_content.is_some(),\n value_text_len(reasoning_content),\n )\n}\n\nfn thinking_field_text(value: &Value) -> String {\n value\n .get(\"type\")\n .and_then(Value::as_str)\n .map(ToString::to_string)\n .unwrap_or_else(|| {\n serde_json::to_string(value).unwrap_or_else(|_| json_type_name(value).to_string())\n })\n}\n\nfn content_value_to_text(value: &Value) -> Option<String> {\n match value {\n Value::String(text) => Some(text.clone()),\n Value::Array(items) => {\n let mut output = String::new();\n for item in items {\n let Some(object) = item.as_object() else {\n continue;\n };\n if object.get(\"type\").and_then(Value::as_str) == Some(\"text\") {\n if let Some(text) = object.get(\"text\").and_then(Value::as_str) {\n output.push_str(text);\n }\n }\n }\n Some(output)\n }\n _ => None,\n }\n}\n\nfn validate_content_has_jsonish_text(content: String, summary: &str) -> Result<String, String> {\n if content.contains('{') || content.contains('[') {\n Ok(content)\n } else {\n Err(format!(\n \"AI provider returned non-JSON message content. content_preview=\\\"{}\\\". {summary}\",\n preview_text(&content, 300)\n ))\n }\n}\n\nfn value_text_len(value: Option<&Value>) -> usize {\n value\n .and_then(content_value_to_text)\n .map(|text| text.chars().count())\n .unwrap_or(0)\n}\n\nfn json_type_or_string(value: &Value) -> String {\n value\n .as_str()\n .map(ToString::to_string)\n .unwrap_or_else(|| json_type_name(value).to_string())\n}\n\nfn json_type_name(value: &Value) -> &'static str {\n match value {\n Value::Null => \"null\",\n Value::Bool(_) => \"boolean\",\n Value::Number(_) => \"number\",\n Value::String(_) => \"string\",\n Value::Array(_) => \"array\",\n Value::Object(_) => \"object\",\n }\n}\n\nfn preview_text(text: &str, limit: usize) -> String {\n let mut preview = text\n .chars()\n .take(limit)\n .collect::<String>()\n .replace(['\\r', '\\n', '\\t'], \" \");\n if text.chars().count() > limit {\n preview.push_str(\"...\");\n }\n preview\n}\n\nfn short_response(text: &str) -> String {\n const LIMIT: usize = 500;\n if text.len() <= LIMIT {\n text.to_string()\n } else {\n format!(\"{}...\", &text[..LIMIT])\n }\n}\n\nfn redact_api_key(message: &str, api_key: &str) -> String {\n let trimmed = api_key.trim();\n if trimmed.is_empty() {\n message.to_string()\n } else {\n message.replace(trimmed, \"[redacted]\")\n }\n}\n\n#[cfg(test)]\nmod tests {\n use super::*;\n\n #[test]\n fn extra_body_cannot_override_reserved_fields() {\n let mut body = Map::from_iter([\n (\"model\".to_string(), json!(\"trusted-model\")),\n (\n \"messages\".to_string(),\n json!([{\"role\":\"user\",\"content\":\"safe\"}]),\n ),\n (\"temperature\".to_string(), json!(0.2)),\n (\"max_tokens\".to_string(), json!(1024)),\n (\"response_format\".to_string(), json!({\"type\":\"json_object\"})),\n ]);\n\n merge_extra_body(\n &mut body,\n Some(r#\"{\"model\":\"attacker\",\"messages\":[],\"stream\":true,\"temperature\":2,\"max_tokens\":999999,\"max_completion_tokens\":999999,\"response_format\":{\"type\":\"text\"},\"thinking\":{\"type\":\"enabled\"},\"reasoning_effort\":\"high\",\"safe_extension\":true}\"#),\n \"\",\n )\n .expect(\"merge safe extension fields\");\n\n assert_eq!(body[\"model\"], \"trusted-model\");\n assert_eq!(body[\"messages\"][0][\"content\"], \"safe\");\n assert_eq!(body[\"temperature\"], 0.2);\n assert_eq!(body[\"max_tokens\"], 1024);\n assert_eq!(body[\"response_format\"][\"type\"], \"json_object\");\n for key in [\n \"stream\",\n \"max_completion_tokens\",\n \"thinking\",\n \"reasoning_effort\",\n ] {\n assert!(!body.contains_key(key), \"{key}\");\n }\n assert_eq!(body[\"safe_extension\"], true);\n }\n\n #[test]\n fn parse_openai_content_reads_normal_string_content() {\n let text =\n r#\"{\"choices\":[{\"finish_reason\":\"stop\",\"message\":{\"content\":\"{\\\"ok\\\":true}\"}}]}\"#;\n let content = parse_openai_content(text).expect(\"parse content\");\n assert_eq!(content, r#\"{\"ok\":true}\"#);\n }\n\n #[test]\n fn parse_openai_content_reports_empty_content() {\n let text = r#\"{\"choices\":[{\"finish_reason\":\"stop\",\"message\":{\"content\":\" \"}}]}\"#;\n let error = parse_openai_content(text).expect_err(\"empty content should fail\");\n assert!(error.contains(\"AI provider returned empty message.content.\"));\n assert!(error.contains(\"has_choices=true\"));\n assert!(error.contains(\"content_type=string\"));\n assert!(error.contains(\"content_length=2\"));\n }\n\n #[test]\n fn parse_openai_content_reports_reasoning_without_final_content() {\n let text = r#\"{\"choices\":[{\"finish_reason\":\"stop\",\"message\":{\"content\":\"\",\"reasoning_content\":\"thinking trace\"}}]}\"#;\n let error = parse_openai_content(text).expect_err(\"reasoning-only response should fail\");\n assert!(error.contains(\"AI provider returned reasoning_content but empty content.\"));\n assert!(error.contains(\"Please disable Thinking or use a non-thinking model.\"));\n assert!(error.contains(\"has_reasoning_content=true\"));\n assert!(error.contains(\"reasoning_content_length=14\"));\n }\n\n #[test]\n fn parse_openai_content_reads_array_text_content() {\n let text = r#\"{\"choices\":[{\"finish_reason\":\"stop\",\"message\":{\"content\":[{\"type\":\"text\",\"text\":\"{\\\"ok\\\":\"},{\"type\":\"text\",\"text\":\"true}\"}]}}]}\"#;\n let content = parse_openai_content(text).expect(\"parse text parts\");\n assert_eq!(content, r#\"{\"ok\":true}\"#);\n }\n\n #[test]\n fn parse_openai_content_reads_message_output_text() {\n let text =\n r#\"{\"choices\":[{\"finish_reason\":\"stop\",\"message\":{\"output_text\":\"{\\\"ok\\\":true}\"}}]}\"#;\n let content = parse_openai_content(text).expect(\"parse output_text\");\n assert_eq!(content, r#\"{\"ok\":true}\"#);\n }\n\n #[test]\n fn parse_openai_content_reads_message_text() {\n let text = r#\"{\"choices\":[{\"finish_reason\":\"stop\",\"message\":{\"text\":\"{\\\"ok\\\":true}\"}}]}\"#;\n let content = parse_openai_content(text).expect(\"parse text\");\n assert_eq!(content, r#\"{\"ok\":true}\"#);\n }\n\n #[test]\n fn parse_openai_content_includes_short_non_json_preview() {\n let text = r#\"{\"choices\":[{\"finish_reason\":\"stop\",\"message\":{\"content\":\"Sure, I can classify that file.\"}}]}\"#;\n let error = parse_openai_content(text).expect_err(\"non-json content should fail\");\n assert!(error.contains(\"AI provider returned non-JSON message content.\"));\n assert!(error.contains(\"content_preview=\\\"Sure, I can classify that file.\\\"\"));\n assert!(error.contains(\"message_keys=[content]\"));\n }\n\n #[test]\n fn provider_error_redaction_removes_api_key_from_summary() {\n let api_key = \"sk-provider-secret\";\n let text = r#\"{\"choices\":[{\"finish_reason\":\"stop\",\"message\":{\"content\":\"Provider rejected sk-provider-secret\"}}]}\"#;\n let error = parse_openai_content(text).expect_err(\"non-json content should fail\");\n let redacted = redact_api_key(&error, api_key);\n assert!(!redacted.contains(api_key));\n assert!(redacted.contains(\"[redacted]\"));\n }\n}\n"}, "d1b61a1174b7714e02c121f8f468db81e6a161ff": {"t": 1785757172.2770529, "n": 23133, "h": "468daa46bde81a9e", "c": "use std::time::Duration;\n\nuse reqwest::blocking::Client;\nuse serde_json::{json, Map, Value};\n\nuse super::{\n presets::{provider_preset, AIProviderPreset},\n provider::{AIProvider, AIProviderError},\n schema::{AIChatMessage, AIChatRequest, AIConnectionTestResult, AIProviderPresetId},\n settings::AISettings,\n};\n\ntype ChatBody = (Map<String, Value>, bool, Option<String>);\n\npub struct OpenAICompatibleProvider {\n settings: AISettings,\n preset: AIProviderPreset,\n client: Option<Client>,\n}\n\n#[derive(Debug, Clone)]\npub struct AIRawProviderResponse {\n pub status: u16,\n pub response_text: String,\n pub request_used_response_format: bool,\n pub request_used_thinking_field: Option<String>,\n pub response_summary: String,\n}\n\n#[derive(Debug, Clone, serde::Serialize)]\npub struct AIDebugExtractResult {\n pub finish_reason: Option<String>,\n pub message_keys: Vec<String>,\n pub message_content: Option<String>,\n pub reasoning_content: Option<String>,\n pub output_text: Option<String>,\n pub text: Option<String>,\n pub extracted_content: Option<String>,\n pub parse_error: Option<String>,\n}\n\nimpl OpenAICompatibleProvider {\n pub fn new(settings: AISettings) -> Self {\n let preset = provider_preset(settings.preset)\n .or_else(|| provider_preset(AIProviderPresetId::CustomOpenAICompatible))\n .expect(\"custom OpenAI-compatible preset exists\");\n let client = Client::builder()\n .timeout(Duration::from_secs(settings.timeout_seconds.max(1)))\n .build()\n .ok();\n Self {\n settings,\n preset,\n client,\n }\n }\n\n fn request_url(&self) -> Result<String, AIProviderError> {\n join_base_url_and_chat_path(&self.settings.base_url, &self.settings.chat_path)\n }\n\n fn client(&self) -> Result<&Client, AIProviderError> {\n self.client\n .as_ref()\n .ok_or_else(|| self.error(\"failed to build AI HTTP client\"))\n }\n\n fn error(&self, message: impl Into<String>) -> AIProviderError {\n AIProviderError::new(redact_api_key(&message.into(), &self.settings.api_key))\n }\n\n pub fn send_chat_request_raw(\n &self,\n request: AIChatRequest,\n ) -> Result<AIRawProviderResponse, AIProviderError> {\n let url = self.request_url()?;\n let (body, request_used_response_format, request_used_thinking_field) =\n self.build_chat_body(&request)?;\n\n let mut builder = self\n .client()?\n .post(url)\n .header(reqwest::header::CONTENT_TYPE, \"application/json\");\n if !self.settings.api_key.trim().is_empty() {\n builder = builder.bearer_auth(self.settings.api_key.trim());\n }\n\n let response = builder\n .json(&Value::Object(body))\n .send()\n .map_err(|error| self.error(format!(\"AI request failed: {error}\")))?;\n let status = response.status();\n let response_text = response\n .text()\n .map_err(|error| self.error(format!(\"failed to read AI response: {error}\")))?;\n let response_summary = serde_json::from_str::<Value>(&response_text)\n .map(|value| summarize_provider_response(&value))\n .unwrap_or_else(|error| format!(\"provider response summary: invalid_json={error}\"));\n\n Ok(AIRawProviderResponse {\n status: status.as_u16(),\n response_text: redact_api_key(&response_text, &self.settings.api_key),\n request_used_response_format,\n request_used_thinking_field,\n response_summary,\n })\n }\n\n fn build_chat_body(&self, request: &AIChatRequest) -> Result<ChatBody, AIProviderError> {\n let mut body = Map::new();\n body.insert(\"model\".to_string(), json!(request.model));\n body.insert(\n \"messages\".to_string(),\n json!(messages_with_instructions(\n &request.messages,\n request.force_json,\n thinking_enabled(&self.settings, request),\n self.preset.id\n )),\n );\n body.insert(\"temperature\".to_string(), json!(request.temperature));\n body.insert(\"max_tokens\".to_string(), json!(request.max_tokens));\n\n let response_format_enabled = request\n .provider_options\n .use_response_format\n .unwrap_or(self.preset.supports_response_format);\n if request.force_json && response_format_enabled {\n body.insert(\n \"response_format\".to_string(),\n json!({ \"type\": \"json_object\" }),\n );\n }\n\n // Advanced override: user-provided extra_body_json is merged last and can override\n // provider fields intentionally. The settings UI labels this as an advanced option.\n merge_extra_body(\n &mut body,\n self.settings.extra_body_json.as_deref(),\n &self.settings.api_key,\n )\n .map_err(|error| self.error(error.to_string()))?;\n merge_extra_body(\n &mut body,\n request.provider_options.extra_body_json.as_deref(),\n &self.settings.api_key,\n )\n .map_err(|error| self.error(error.to_string()))?;\n\n let enable_thinking = thinking_enabled(&self.settings, request);\n if enable_thinking && self.preset.supports_reasoning_effort {\n if let Some(reasoning_effort) = request\n .provider_options\n .reasoning_effort\n .as_deref()\n .or(self.settings.reasoning_effort.as_deref())\n .filter(|value| !value.trim().is_empty())\n {\n body.insert(\"reasoning_effort\".to_string(), json!(reasoning_effort));\n }\n }\n if self.preset.id == AIProviderPresetId::DeepSeek\n && self.preset.supports_thinking\n && !body.contains_key(\"thinking\")\n {\n body.insert(\n \"thinking\".to_string(),\n json!({ \"type\": if enable_thinking { \"enabled\" } else { \"disabled\" } }),\n );\n }\n\n let request_used_thinking_field = body.get(\"thinking\").map(thinking_field_text);\n Ok((\n body,\n request.force_json && response_format_enabled,\n request_used_thinking_field,\n ))\n }\n}\n\nimpl AIProvider for OpenAICompatibleProvider {\n fn chat_json(&self, request: AIChatRequest) -> Result<String, AIProviderError> {\n let raw = self.send_chat_request_raw(request.clone())?;\n if !(200..300).contains(&raw.status) {\n let mut message = format!(\n \"AI provider returned HTTP {}: {}\",\n raw.status,\n short_response(&raw.response_text)\n );\n if self.preset.id == AIProviderPresetId::DeepSeek\n && !thinking_enabled(&self.settings, &request)\n {\n message.push_str(\n \" If DeepSeek rejects thinking disabled, use a non-thinking model or a compatible legacy model name.\",\n );\n }\n return Err(self.error(message));\n }\n\n parse_openai_content(&raw.response_text).map_err(|error| self.error(error))\n }\n\n fn test_connection(&self) -> Result<AIConnectionTestResult, AIProviderError> {\n let mut prompt = \"Return exactly this JSON and nothing else: {\\\"ok\\\":true}\\nDo not output Markdown.\\nDo not output reasoning.\\nDo not output <think>.\\nDo not explain.\".to_string();\n if self.preset.id == AIProviderPresetId::DeepSeek && !self.settings.enable_thinking {\n prompt.push_str(\"\\nUse non-thinking mode and only return final content.\");\n }\n let test_max_tokens = self.settings.max_tokens.clamp(512, 4096);\n let content = self.chat_json(AIChatRequest {\n messages: vec![AIChatMessage {\n role: \"user\".to_string(),\n content: prompt,\n }],\n model: self.settings.model.clone(),\n temperature: 0.0,\n max_tokens: test_max_tokens,\n force_json: true,\n provider_options: Default::default(),\n })?;\n Ok(AIConnectionTestResult {\n ok: true,\n message: format!(\"AI provider responded: {}\", short_response(&content)),\n model: Some(self.settings.model.clone()),\n provider: Some(self.settings.provider),\n preset: Some(self.settings.preset),\n elapsed_ms: 0,\n })\n }\n}\n\npub fn join_base_url_and_chat_path(\n base_url: &str,\n chat_path: &str,\n) -> Result<String, AIProviderError> {\n let base = base_url.trim().trim_end_matches('/');\n let path = chat_path.trim().trim_start_matches('/');\n if base.is_empty() {\n return Err(AIProviderError::new(\"AI provider base_url is empty.\"));\n }\n if path.is_empty() {\n return Err(AIProviderError::new(\"AI provider chat_path is empty.\"));\n }\n Ok(format!(\"{base}/{path}\"))\n}\n\nfn messages_with_instructions(\n messages: &[AIChatMessage],\n force_json: bool,\n enable_thinking: bool,\n preset_id: AIProviderPresetId,\n) -> Vec<AIChatMessage> {\n let mut output = Vec::new();\n let mut instructions = Vec::new();\n if force_json {\n instructions.push(\"Return only valid JSON. Do not wrap the JSON in markdown.\".to_string());\n }\n if !enable_thinking {\n instructions.push(\"Do not output thinking, reasoning traces, or explanations.\".to_string());\n }\n if preset_id == AIProviderPresetId::DeepSeek {\n instructions.push(\n \"DeepSeek legacy model names deepseek-chat and deepseek-reasoner may be deprecated; keep output compatible with the selected model.\".to_string(),\n );\n }\n if !instructions.is_empty() {\n output.push(AIChatMessage {\n role: \"system\".to_string(),\n content: instructions.join(\" \"),\n });\n }\n output.extend_from_slice(messages);\n output\n}\n\nfn thinking_enabled(settings: &AISettings, request: &AIChatRequest) -> bool {\n request\n .provider_options\n .enable_thinking\n .unwrap_or(settings.enable_thinking)\n}\n\nfn merge_extra_body(\n body: &mut Map<String, Value>,\n extra_body_json: Option<&str>,\n api_key: &str,\n) -> Result<(), AIProviderError> {\n let Some(extra_body_json) = extra_body_json.filter(|value| !value.trim().is_empty()) else {\n return Ok(());\n };\n let value = serde_json::from_str::<Value>(extra_body_json).map_err(|error| {\n AIProviderError::new(redact_api_key(\n &format!(\"AI extra_body_json must be a JSON object: {error}\"),\n api_key,\n ))\n })?;\n let Value::Object(extra) = value else {\n return Err(AIProviderError::new(\n \"AI extra_body_json must be a JSON object.\",\n ));\n };\n for (key, value) in extra {\n body.insert(key, value);\n }\n Ok(())\n}\n\nfn parse_openai_content(text: &str) -> Result<String, String> {\n let value = serde_json::from_str::<Value>(text)\n .map_err(|error| format!(\"AI provider returned invalid JSON: {error}\"))?;\n let summary = summarize_provider_response(&value);\n let Some(choice) = value\n .get(\"choices\")\n .and_then(Value::as_array)\n .and_then(|choices| choices.first())\n else {\n return Err(format!(\n \"AI provider response did not contain choices[0]. {summary}\"\n ));\n };\n let Some(message) = choice.get(\"message\").and_then(Value::as_object) else {\n return Err(format!(\n \"AI provider response did not contain choices[0].message. {summary}\"\n ));\n };\n\n if let Some(content) = message.get(\"content\") {\n let content = content_value_to_text(content).ok_or_else(|| {\n format!(\"AI provider response had unsupported message.content shape. {summary}\")\n })?;\n if content.trim().is_empty() {\n if value_text_len(message.get(\"reasoning_content\")) > 0 {\n if choice.get(\"finish_reason\").and_then(Value::as_str) == Some(\"length\") {\n return Err(format!(\n \"AI provider returned reasoning_content but empty content. 模型只返回了 reasoning_content,且因为输出长度限制被截断,没有生成最终 content。请关闭 Thinking,并提高连接测试 max_tokens;Zen Canvas 已不再使用 64 token 进行连接测试。 {summary}\"\n ));\n }\n return Err(format!(\n \"AI provider returned reasoning_content but empty content. Please disable Thinking or use a non-thinking model. {summary}\"\n ));\n }\n return Err(format!(\n \"AI provider returned empty message.content. {summary}\"\n ));\n }\n return validate_content_has_jsonish_text(content, &summary);\n }\n\n for fallback_key in [\"output_text\", \"text\"] {\n if let Some(content) = message.get(fallback_key) {\n let content = content_value_to_text(content).ok_or_else(|| {\n format!(\n \"AI provider response had unsupported message.{fallback_key} shape. {summary}\"\n )\n })?;\n if content.trim().is_empty() {\n continue;\n }\n return validate_content_has_jsonish_text(content, &summary);\n }\n }\n\n Err(format!(\n \"AI provider response did not contain message.content, message.output_text, or message.text. {summary}\"\n ))\n}\n\npub fn debug_extract_openai_response(raw_response: &str) -> AIDebugExtractResult {\n let value = match serde_json::from_str::<Value>(raw_response) {\n Ok(value) => value,\n Err(error) => {\n return AIDebugExtractResult {\n finish_reason: None,\n message_keys: Vec::new(),\n message_content: None,\n reasoning_content: None,\n output_text: None,\n text: None,\n extracted_content: None,\n parse_error: Some(format!(\"provider raw response is not JSON: {error}\")),\n };\n }\n };\n let choice = value\n .get(\"choices\")\n .and_then(Value::as_array)\n .and_then(|choices| choices.first());\n let message = choice\n .and_then(|choice| choice.get(\"message\"))\n .and_then(Value::as_object);\n let mut message_keys = message\n .map(|message| message.keys().cloned().collect::<Vec<_>>())\n .unwrap_or_default();\n message_keys.sort();\n let message_content = message\n .and_then(|message| message.get(\"content\"))\n .and_then(content_value_to_text);\n let output_text = message\n .and_then(|message| message.get(\"output_text\"))\n .and_then(content_value_to_text);\n let text = message\n .and_then(|message| message.get(\"text\"))\n .and_then(content_value_to_text);\n let extracted_content = message_content\n .clone()\n .or_else(|| output_text.clone())\n .or_else(|| text.clone());\n\n AIDebugExtractResult {\n finish_reason: choice\n .and_then(|choice| choice.get(\"finish_reason\"))\n .and_then(Value::as_str)\n .map(ToString::to_string),\n message_keys,\n message_content,\n reasoning_content: message\n .and_then(|message| message.get(\"reasoning_content\"))\n .and_then(content_value_to_text),\n output_text,\n text,\n extracted_content,\n parse_error: message\n .is_none()\n .then(|| \"provider response did not contain choices[0].message\".to_string()),\n }\n}\n\npub(crate) fn summarize_provider_response(value: &Value) -> String {\n let choices = value.get(\"choices\").and_then(Value::as_array);\n let choice = choices.and_then(|items| items.first());\n let message = choice\n .and_then(|choice| choice.get(\"message\"))\n .and_then(Value::as_object);\n let mut message_keys = message\n .map(|message| message.keys().cloned().collect::<Vec<_>>())\n .unwrap_or_default();\n message_keys.sort();\n let content = message.and_then(|message| message.get(\"content\"));\n let reasoning_content = message.and_then(|message| message.get(\"reasoning_content\"));\n format!(\n \"provider response summary: has_choices={}; choice_count={}; finish_reason={}; message_keys={}; content_type={}; content_length={}; has_reasoning_content={}; reasoning_content_length={}\",\n choices.is_some(),\n choices.map_or(0, Vec::len),\n choice\n .and_then(|choice| choice.get(\"finish_reason\"))\n .map(json_type_or_string)\n .unwrap_or_else(|| \"missing\".to_string()),\n if message_keys.is_empty() {\n \"[]\".to_string()\n } else {\n format!(\"[{}]\", message_keys.join(\",\"))\n },\n content.map(json_type_name).unwrap_or(\"missing\"),\n value_text_len(content),\n reasoning_content.is_some(),\n value_text_len(reasoning_content),\n )\n}\n\nfn thinking_field_text(value: &Value) -> String {\n value\n .get(\"type\")\n .and_then(Value::as_str)\n .map(ToString::to_string)\n .unwrap_or_else(|| {\n serde_json::to_string(value).unwrap_or_else(|_| json_type_name(value).to_string())\n })\n}\n\nfn content_value_to_text(value: &Value) -> Option<String> {\n match value {\n Value::String(text) => Some(text.clone()),\n Value::Array(items) => {\n let mut output = String::new();\n for item in items {\n let Some(object) = item.as_object() else {\n continue;\n };\n if object.get(\"type\").and_then(Value::as_str) == Some(\"text\") {\n if let Some(text) = object.get(\"text\").and_then(Value::as_str) {\n output.push_str(text);\n }\n }\n }\n Some(output)\n }\n _ => None,\n }\n}\n\nfn validate_content_has_jsonish_text(content: String, summary: &str) -> Result<String, String> {\n if content.contains('{') || content.contains('[') {\n Ok(content)\n } else {\n Err(format!(\n \"AI provider returned non-JSON message content. content_preview=\\\"{}\\\". {summary}\",\n preview_text(&content, 300)\n ))\n }\n}\n\nfn value_text_len(value: Option<&Value>) -> usize {\n value\n .and_then(content_value_to_text)\n .map(|text| text.chars().count())\n .unwrap_or(0)\n}\n\nfn json_type_or_string(value: &Value) -> String {\n value\n .as_str()\n .map(ToString::to_string)\n .unwrap_or_else(|| json_type_name(value).to_string())\n}\n\nfn json_type_name(value: &Value) -> &'static str {\n match value {\n Value::Null => \"null\",\n Value::Bool(_) => \"boolean\",\n Value::Number(_) => \"number\",\n Value::String(_) => \"string\",\n Value::Array(_) => \"array\",\n Value::Object(_) => \"object\",\n }\n}\n\nfn preview_text(text: &str, limit: usize) -> String {\n let mut preview = text\n .chars()\n .take(limit)\n .collect::<String>()\n .replace(['\\r', '\\n', '\\t'], \" \");\n if text.chars().count() > limit {\n preview.push_str(\"...\");\n }\n preview\n}\n\nfn short_response(text: &str) -> String {\n const LIMIT: usize = 500;\n if text.len() <= LIMIT {\n text.to_string()\n } else {\n format!(\"{}...\", &text[..LIMIT])\n }\n}\n\nfn redact_api_key(message: &str, api_key: &str) -> String {\n let trimmed = api_key.trim();\n if trimmed.is_empty() {\n message.to_string()\n } else {\n message.replace(trimmed, \"[redacted]\")\n }\n}\n\n#[cfg(test)]\nmod tests {\n use super::*;\n\n #[test]\n fn parse_openai_content_reads_normal_string_content() {\n let text =\n r#\"{\"choices\":[{\"finish_reason\":\"stop\",\"message\":{\"content\":\"{\\\"ok\\\":true}\"}}]}\"#;\n let content = parse_openai_content(text).expect(\"parse content\");\n assert_eq!(content, r#\"{\"ok\":true}\"#);\n }\n\n #[test]\n fn parse_openai_content_reports_empty_content() {\n let text = r#\"{\"choices\":[{\"finish_reason\":\"stop\",\"message\":{\"content\":\" \"}}]}\"#;\n let error = parse_openai_content(text).expect_err(\"empty content should fail\");\n assert!(error.contains(\"AI provider returned empty message.content.\"));\n assert!(error.contains(\"has_choices=true\"));\n assert!(error.contains(\"content_type=string\"));\n assert!(error.contains(\"content_length=2\"));\n }\n\n #[test]\n fn parse_openai_content_reports_reasoning_without_final_content() {\n let text = r#\"{\"choices\":[{\"finish_reason\":\"stop\",\"message\":{\"content\":\"\",\"reasoning_content\":\"thinking trace\"}}]}\"#;\n let error = parse_openai_content(text).expect_err(\"reasoning-only response should fail\");\n assert!(error.contains(\"AI provider returned reasoning_content but empty content.\"));\n assert!(error.contains(\"Please disable Thinking or use a non-thinking model.\"));\n assert!(error.contains(\"has_reasoning_content=true\"));\n assert!(error.contains(\"reasoning_content_length=14\"));\n }\n\n #[test]\n fn parse_openai_content_reads_array_text_content() {\n let text = r#\"{\"choices\":[{\"finish_reason\":\"stop\",\"message\":{\"content\":[{\"type\":\"text\",\"text\":\"{\\\"ok\\\":\"},{\"type\":\"text\",\"text\":\"true}\"}]}}]}\"#;\n let content = parse_openai_content(text).expect(\"parse text parts\");\n assert_eq!(content, r#\"{\"ok\":true}\"#);\n }\n\n #[test]\n fn parse_openai_content_reads_message_output_text() {\n let text =\n r#\"{\"choices\":[{\"finish_reason\":\"stop\",\"message\":{\"output_text\":\"{\\\"ok\\\":true}\"}}]}\"#;\n let content = parse_openai_content(text).expect(\"parse output_text\");\n assert_eq!(content, r#\"{\"ok\":true}\"#);\n }\n\n #[test]\n fn parse_openai_content_reads_message_text() {\n let text = r#\"{\"choices\":[{\"finish_reason\":\"stop\",\"message\":{\"text\":\"{\\\"ok\\\":true}\"}}]}\"#;\n let content = parse_openai_content(text).expect(\"parse text\");\n assert_eq!(content, r#\"{\"ok\":true}\"#);\n }\n\n #[test]\n fn parse_openai_content_includes_short_non_json_preview() {\n let text = r#\"{\"choices\":[{\"finish_reason\":\"stop\",\"message\":{\"content\":\"Sure, I can classify that file.\"}}]}\"#;\n let error = parse_openai_content(text).expect_err(\"non-json content should fail\");\n assert!(error.contains(\"AI provider returned non-JSON message content.\"));\n assert!(error.contains(\"content_preview=\\\"Sure, I can classify that file.\\\"\"));\n assert!(error.contains(\"message_keys=[content]\"));\n }\n\n #[test]\n fn provider_error_redaction_removes_api_key_from_summary() {\n let api_key = \"sk-provider-secret\";\n let text = r#\"{\"choices\":[{\"finish_reason\":\"stop\",\"message\":{\"content\":\"Provider rejected sk-provider-secret\"}}]}\"#;\n let error = parse_openai_content(text).expect_err(\"non-json content should fail\");\n let redacted = redact_api_key(&error, api_key);\n assert!(!redacted.contains(api_key));\n assert!(redacted.contains(\"[redacted]\"));\n }\n}\n"}, "e067afd3e2389f66cdb3852eb86715949d881fd4": {"t": 1785757173.880474, "n": 23000, "h": "3036929a9c2d1871", "c": "use std::time::Duration;\n\nuse reqwest::blocking::Client;\nuse serde_json::{json, Map, Value};\n\nuse super::{\n presets::{provider_preset, AIProviderPreset},\n provider::{AIProvider, AIProviderError},\n schema::{AIChatMessage, AIChatRequest, AIConnectionTestResult, AIProviderPresetId},\n settings::AISettings,\n};\n\npub struct OpenAICompatibleProvider {\n settings: AISettings,\n preset: AIProviderPreset,\n}\n\n#[derive(Debug, Clone)]\npub struct AIRawProviderResponse {\n pub status: u16,\n pub response_text: String,\n pub request_used_response_format: bool,\n pub request_used_thinking_field: Option<String>,\n pub response_summary: String,\n}\n\n#[derive(Debug, Clone, serde::Serialize)]\npub struct AIDebugExtractResult {\n pub finish_reason: Option<String>,\n pub message_keys: Vec<String>,\n pub message_content: Option<String>,\n pub reasoning_content: Option<String>,\n pub output_text: Option<String>,\n pub text: Option<String>,\n pub extracted_content: Option<String>,\n pub parse_error: Option<String>,\n}\n\nimpl OpenAICompatibleProvider {\n pub fn new(settings: AISettings) -> Self {\n let preset = provider_preset(settings.preset)\n .or_else(|| provider_preset(AIProviderPresetId::CustomOpenAICompatible))\n .expect(\"custom OpenAI-compatible preset exists\");\n Self { settings, preset }\n }\n\n fn request_url(&self) -> Result<String, AIProviderError> {\n join_base_url_and_chat_path(&self.settings.base_url, &self.settings.chat_path)\n }\n\n fn client(&self) -> Result<Client, AIProviderError> {\n Client::builder()\n .timeout(Duration::from_secs(self.settings.timeout_seconds.max(1)))\n .build()\n .map_err(|error| self.error(format!(\"failed to build AI HTTP client: {error}\")))\n }\n\n fn error(&self, message: impl Into<String>) -> AIProviderError {\n AIProviderError::new(redact_api_key(&message.into(), &self.settings.api_key))\n }\n\n pub fn send_chat_request_raw(\n &self,\n request: AIChatRequest,\n ) -> Result<AIRawProviderResponse, AIProviderError> {\n let url = self.request_url()?;\n let (body, request_used_response_format, request_used_thinking_field) =\n self.build_chat_body(&request)?;\n\n let mut builder = self\n .client()?\n .post(url)\n .header(reqwest::header::CONTENT_TYPE, \"application/json\");\n if !self.settings.api_key.trim().is_empty() {\n builder = builder.bearer_auth(self.settings.api_key.trim());\n }\n\n let response = builder\n .json(&Value::Object(body))\n .send()\n .map_err(|error| self.error(format!(\"AI request failed: {error}\")))?;\n let status = response.status();\n let response_text = response\n .text()\n .map_err(|error| self.error(format!(\"failed to read AI response: {error}\")))?;\n let response_summary = serde_json::from_str::<Value>(&response_text)\n .map(|value| summarize_provider_response(&value))\n .unwrap_or_else(|error| format!(\"provider response summary: invalid_json={error}\"));\n\n Ok(AIRawProviderResponse {\n status: status.as_u16(),\n response_text: redact_api_key(&response_text, &self.settings.api_key),\n request_used_response_format,\n request_used_thinking_field,\n response_summary,\n })\n }\n\n fn build_chat_body(\n &self,\n request: &AIChatRequest,\n ) -> Result<(Map<String, Value>, bool, Option<String>), AIProviderError> {\n let mut body = Map::new();\n body.insert(\"model\".to_string(), json!(request.model));\n body.insert(\n \"messages\".to_string(),\n json!(messages_with_instructions(\n &request.messages,\n request.force_json,\n thinking_enabled(&self.settings, request),\n self.preset.id\n )),\n );\n body.insert(\"temperature\".to_string(), json!(request.temperature));\n body.insert(\"max_tokens\".to_string(), json!(request.max_tokens));\n\n let response_format_enabled = request\n .provider_options\n .use_response_format\n .unwrap_or(self.preset.supports_response_format);\n if request.force_json && response_format_enabled {\n body.insert(\n \"response_format\".to_string(),\n json!({ \"type\": \"json_object\" }),\n );\n }\n\n // Advanced override: user-provided extra_body_json is merged last and can override\n // provider fields intentionally. The settings UI labels this as an advanced option.\n merge_extra_body(\n &mut body,\n self.settings.extra_body_json.as_deref(),\n &self.settings.api_key,\n )\n .map_err(|error| self.error(error.to_string()))?;\n merge_extra_body(\n &mut body,\n request.provider_options.extra_body_json.as_deref(),\n &self.settings.api_key,\n )\n .map_err(|error| self.error(error.to_string()))?;\n\n let enable_thinking = thinking_enabled(&self.settings, request);\n if enable_thinking && self.preset.supports_reasoning_effort {\n if let Some(reasoning_effort) = request\n .provider_options\n .reasoning_effort\n .as_deref()\n .or(self.settings.reasoning_effort.as_deref())\n .filter(|value| !value.trim().is_empty())\n {\n body.insert(\"reasoning_effort\".to_string(), json!(reasoning_effort));\n }\n }\n if self.preset.id == AIProviderPresetId::DeepSeek\n && self.preset.supports_thinking\n && !body.contains_key(\"thinking\")\n {\n body.insert(\n \"thinking\".to_string(),\n json!({ \"type\": if enable_thinking { \"enabled\" } else { \"disabled\" } }),\n );\n }\n\n let request_used_thinking_field = body.get(\"thinking\").map(thinking_field_text);\n Ok((\n body,\n request.force_json && response_format_enabled,\n request_used_thinking_field,\n ))\n }\n}\n\nimpl AIProvider for OpenAICompatibleProvider {\n fn chat_json(&self, request: AIChatRequest) -> Result<String, AIProviderError> {\n let raw = self.send_chat_request_raw(request.clone())?;\n if !(200..300).contains(&raw.status) {\n let mut message = format!(\n \"AI provider returned HTTP {}: {}\",\n raw.status,\n short_response(&raw.response_text)\n );\n if self.preset.id == AIProviderPresetId::DeepSeek\n && !thinking_enabled(&self.settings, &request)\n {\n message.push_str(\n \" If DeepSeek rejects thinking disabled, use a non-thinking model or a compatible legacy model name.\",\n );\n }\n return Err(self.error(message));\n }\n\n parse_openai_content(&raw.response_text).map_err(|error| self.error(error))\n }\n\n fn test_connection(&self) -> Result<AIConnectionTestResult, AIProviderError> {\n let mut prompt = \"Return exactly this JSON and nothing else: {\\\"ok\\\":true}\\nDo not output Markdown.\\nDo not output reasoning.\\nDo not output <think>.\\nDo not explain.\".to_string();\n if self.preset.id == AIProviderPresetId::DeepSeek && !self.settings.enable_thinking {\n prompt.push_str(\"\\nUse non-thinking mode and only return final content.\");\n }\n let test_max_tokens = self.settings.max_tokens.max(512).min(4096);\n let content = self.chat_json(AIChatRequest {\n messages: vec![AIChatMessage {\n role: \"user\".to_string(),\n content: prompt,\n }],\n model: self.settings.model.clone(),\n temperature: 0.0,\n max_tokens: test_max_tokens,\n force_json: true,\n provider_options: Default::default(),\n })?;\n Ok(AIConnectionTestResult {\n ok: true,\n message: format!(\"AI provider responded: {}\", short_response(&content)),\n model: Some(self.settings.model.clone()),\n provider: Some(self.settings.provider),\n preset: Some(self.settings.preset),\n elapsed_ms: 0,\n })\n }\n}\n\npub fn join_base_url_and_chat_path(\n base_url: &str,\n chat_path: &str,\n) -> Result<String, AIProviderError> {\n let base = base_url.trim().trim_end_matches('/');\n let path = chat_path.trim().trim_start_matches('/');\n if base.is_empty() {\n return Err(AIProviderError::new(\"AI provider base_url is empty.\"));\n }\n if path.is_empty() {\n return Err(AIProviderError::new(\"AI provider chat_path is empty.\"));\n }\n Ok(format!(\"{base}/{path}\"))\n}\n\nfn messages_with_instructions(\n messages: &[AIChatMessage],\n force_json: bool,\n enable_thinking: bool,\n preset_id: AIProviderPresetId,\n) -> Vec<AIChatMessage> {\n let mut output = Vec::new();\n let mut instructions = Vec::new();\n if force_json {\n instructions.push(\"Return only valid JSON. Do not wrap the JSON in markdown.\".to_string());\n }\n if !enable_thinking {\n instructions.push(\"Do not output thinking, reasoning traces, or explanations.\".to_string());\n }\n if preset_id == AIProviderPresetId::DeepSeek {\n instructions.push(\n \"DeepSeek legacy model names deepseek-chat and deepseek-reasoner may be deprecated; keep output compatible with the selected model.\".to_string(),\n );\n }\n if !instructions.is_empty() {\n output.push(AIChatMessage {\n role: \"system\".to_string(),\n content: instructions.join(\" \"),\n });\n }\n output.extend_from_slice(messages);\n output\n}\n\nfn thinking_enabled(settings: &AISettings, request: &AIChatRequest) -> bool {\n request\n .provider_options\n .enable_thinking\n .unwrap_or(settings.enable_thinking)\n}\n\nfn merge_extra_body(\n body: &mut Map<String, Value>,\n extra_body_json: Option<&str>,\n api_key: &str,\n) -> Result<(), AIProviderError> {\n let Some(extra_body_json) = extra_body_json.filter(|value| !value.trim().is_empty()) else {\n return Ok(());\n };\n let value = serde_json::from_str::<Value>(extra_body_json).map_err(|error| {\n AIProviderError::new(redact_api_key(\n &format!(\"AI extra_body_json must be a JSON object: {error}\"),\n api_key,\n ))\n })?;\n let Value::Object(extra) = value else {\n return Err(AIProviderError::new(\n \"AI extra_body_json must be a JSON object.\",\n ));\n };\n for (key, value) in extra {\n body.insert(key, value);\n }\n Ok(())\n}\n\nfn parse_openai_content(text: &str) -> Result<String, String> {\n let value = serde_json::from_str::<Value>(text)\n .map_err(|error| format!(\"AI provider returned invalid JSON: {error}\"))?;\n let summary = summarize_provider_response(&value);\n let Some(choice) = value\n .get(\"choices\")\n .and_then(Value::as_array)\n .and_then(|choices| choices.first())\n else {\n return Err(format!(\n \"AI provider response did not contain choices[0]. {summary}\"\n ));\n };\n let Some(message) = choice.get(\"message\").and_then(Value::as_object) else {\n return Err(format!(\n \"AI provider response did not contain choices[0].message. {summary}\"\n ));\n };\n\n if let Some(content) = message.get(\"content\") {\n let content = content_value_to_text(content).ok_or_else(|| {\n format!(\"AI provider response had unsupported message.content shape. {summary}\")\n })?;\n if content.trim().is_empty() {\n if value_text_len(message.get(\"reasoning_content\")) > 0 {\n if choice.get(\"finish_reason\").and_then(Value::as_str) == Some(\"length\") {\n return Err(format!(\n \"AI provider returned reasoning_content but empty content. 模型只返回了 reasoning_content,且因为输出长度限制被截断,没有生成最终 content。请关闭 Thinking,并提高连接测试 max_tokens;Zen Canvas 已不再使用 64 token 进行连接测试。 {summary}\"\n ));\n }\n return Err(format!(\n \"AI provider returned reasoning_content but empty content. Please disable Thinking or use a non-thinking model. {summary}\"\n ));\n }\n return Err(format!(\n \"AI provider returned empty message.content. {summary}\"\n ));\n }\n return validate_content_has_jsonish_text(content, &summary);\n }\n\n for fallback_key in [\"output_text\", \"text\"] {\n if let Some(content) = message.get(fallback_key) {\n let content = content_value_to_text(content).ok_or_else(|| {\n format!(\n \"AI provider response had unsupported message.{fallback_key} shape. {summary}\"\n )\n })?;\n if content.trim().is_empty() {\n continue;\n }\n return validate_content_has_jsonish_text(content, &summary);\n }\n }\n\n Err(format!(\n \"AI provider response did not contain message.content, message.output_text, or message.text. {summary}\"\n ))\n}\n\npub fn debug_extract_openai_response(raw_response: &str) -> AIDebugExtractResult {\n let value = match serde_json::from_str::<Value>(raw_response) {\n Ok(value) => value,\n Err(error) => {\n return AIDebugExtractResult {\n finish_reason: None,\n message_keys: Vec::new(),\n message_content: None,\n reasoning_content: None,\n output_text: None,\n text: None,\n extracted_content: None,\n parse_error: Some(format!(\"provider raw response is not JSON: {error}\")),\n };\n }\n };\n let choice = value\n .get(\"choices\")\n .and_then(Value::as_array)\n .and_then(|choices| choices.first());\n let message = choice\n .and_then(|choice| choice.get(\"message\"))\n .and_then(Value::as_object);\n let mut message_keys = message\n .map(|message| message.keys().cloned().collect::<Vec<_>>())\n .unwrap_or_default();\n message_keys.sort();\n let message_content = message\n .and_then(|message| message.get(\"content\"))\n .and_then(content_value_to_text);\n let output_text = message\n .and_then(|message| message.get(\"output_text\"))\n .and_then(content_value_to_text);\n let text = message\n .and_then(|message| message.get(\"text\"))\n .and_then(content_value_to_text);\n let extracted_content = message_content\n .clone()\n .or_else(|| output_text.clone())\n .or_else(|| text.clone());\n\n AIDebugExtractResult {\n finish_reason: choice\n .and_then(|choice| choice.get(\"finish_reason\"))\n .and_then(Value::as_str)\n .map(ToString::to_string),\n message_keys,\n message_content,\n reasoning_content: message\n .and_then(|message| message.get(\"reasoning_content\"))\n .and_then(content_value_to_text),\n output_text,\n text,\n extracted_content,\n parse_error: message\n .is_none()\n .then(|| \"provider response did not contain choices[0].message\".to_string()),\n }\n}\n\npub(crate) fn summarize_provider_response(value: &Value) -> String {\n let choices = value.get(\"choices\").and_then(Value::as_array);\n let choice = choices.and_then(|items| items.first());\n let message = choice\n .and_then(|choice| choice.get(\"message\"))\n .and_then(Value::as_object);\n let mut message_keys = message\n .map(|message| message.keys().cloned().collect::<Vec<_>>())\n .unwrap_or_default();\n message_keys.sort();\n let content = message.and_then(|message| message.get(\"content\"));\n let reasoning_content = message.and_then(|message| message.get(\"reasoning_content\"));\n format!(\n \"provider response summary: has_choices={}; choice_count={}; finish_reason={}; message_keys={}; content_type={}; content_length={}; has_reasoning_content={}; reasoning_content_length={}\",\n choices.is_some(),\n choices.map_or(0, Vec::len),\n choice\n .and_then(|choice| choice.get(\"finish_reason\"))\n .map(json_type_or_string)\n .unwrap_or_else(|| \"missing\".to_string()),\n if message_keys.is_empty() {\n \"[]\".to_string()\n } else {\n format!(\"[{}]\", message_keys.join(\",\"))\n },\n content.map(json_type_name).unwrap_or(\"missing\"),\n value_text_len(content),\n reasoning_content.is_some(),\n value_text_len(reasoning_content),\n )\n}\n\nfn thinking_field_text(value: &Value) -> String {\n value\n .get(\"type\")\n .and_then(Value::as_str)\n .map(ToString::to_string)\n .unwrap_or_else(|| {\n serde_json::to_string(value).unwrap_or_else(|_| json_type_name(value).to_string())\n })\n}\n\nfn content_value_to_text(value: &Value) -> Option<String> {\n match value {\n Value::String(text) => Some(text.clone()),\n Value::Array(items) => {\n let mut output = String::new();\n for item in items {\n let Some(object) = item.as_object() else {\n continue;\n };\n if object.get(\"type\").and_then(Value::as_str) == Some(\"text\") {\n if let Some(text) = object.get(\"text\").and_then(Value::as_str) {\n output.push_str(text);\n }\n }\n }\n Some(output)\n }\n _ => None,\n }\n}\n\nfn validate_content_has_jsonish_text(content: String, summary: &str) -> Result<String, String> {\n if content.contains('{') || content.contains('[') {\n Ok(content)\n } else {\n Err(format!(\n \"AI provider returned non-JSON message content. content_preview=\\\"{}\\\". {summary}\",\n preview_text(&content, 300)\n ))\n }\n}\n\nfn value_text_len(value: Option<&Value>) -> usize {\n value\n .and_then(content_value_to_text)\n .map(|text| text.chars().count())\n .unwrap_or(0)\n}\n\nfn json_type_or_string(value: &Value) -> String {\n value\n .as_str()\n .map(ToString::to_string)\n .unwrap_or_else(|| json_type_name(value).to_string())\n}\n\nfn json_type_name(value: &Value) -> &'static str {\n match value {\n Value::Null => \"null\",\n Value::Bool(_) => \"boolean\",\n Value::Number(_) => \"number\",\n Value::String(_) => \"string\",\n Value::Array(_) => \"array\",\n Value::Object(_) => \"object\",\n }\n}\n\nfn preview_text(text: &str, limit: usize) -> String {\n let mut preview = text\n .chars()\n .take(limit)\n .collect::<String>()\n .replace(['\\r', '\\n', '\\t'], \" \");\n if text.chars().count() > limit {\n preview.push_str(\"...\");\n }\n preview\n}\n\nfn short_response(text: &str) -> String {\n const LIMIT: usize = 500;\n if text.len() <= LIMIT {\n text.to_string()\n } else {\n format!(\"{}...\", &text[..LIMIT])\n }\n}\n\nfn redact_api_key(message: &str, api_key: &str) -> String {\n let trimmed = api_key.trim();\n if trimmed.is_empty() {\n message.to_string()\n } else {\n message.replace(trimmed, \"[redacted]\")\n }\n}\n\n#[cfg(test)]\nmod tests {\n use super::*;\n\n #[test]\n fn parse_openai_content_reads_normal_string_content() {\n let text =\n r#\"{\"choices\":[{\"finish_reason\":\"stop\",\"message\":{\"content\":\"{\\\"ok\\\":true}\"}}]}\"#;\n let content = parse_openai_content(text).expect(\"parse content\");\n assert_eq!(content, r#\"{\"ok\":true}\"#);\n }\n\n #[test]\n fn parse_openai_content_reports_empty_content() {\n let text = r#\"{\"choices\":[{\"finish_reason\":\"stop\",\"message\":{\"content\":\" \"}}]}\"#;\n let error = parse_openai_content(text).expect_err(\"empty content should fail\");\n assert!(error.contains(\"AI provider returned empty message.content.\"));\n assert!(error.contains(\"has_choices=true\"));\n assert!(error.contains(\"content_type=string\"));\n assert!(error.contains(\"content_length=2\"));\n }\n\n #[test]\n fn parse_openai_content_reports_reasoning_without_final_content() {\n let text = r#\"{\"choices\":[{\"finish_reason\":\"stop\",\"message\":{\"content\":\"\",\"reasoning_content\":\"thinking trace\"}}]}\"#;\n let error = parse_openai_content(text).expect_err(\"reasoning-only response should fail\");\n assert!(error.contains(\"AI provider returned reasoning_content but empty content.\"));\n assert!(error.contains(\"Please disable Thinking or use a non-thinking model.\"));\n assert!(error.contains(\"has_reasoning_content=true\"));\n assert!(error.contains(\"reasoning_content_length=14\"));\n }\n\n #[test]\n fn parse_openai_content_reads_array_text_content() {\n let text = r#\"{\"choices\":[{\"finish_reason\":\"stop\",\"message\":{\"content\":[{\"type\":\"text\",\"text\":\"{\\\"ok\\\":\"},{\"type\":\"text\",\"text\":\"true}\"}]}}]}\"#;\n let content = parse_openai_content(text).expect(\"parse text parts\");\n assert_eq!(content, r#\"{\"ok\":true}\"#);\n }\n\n #[test]\n fn parse_openai_content_reads_message_output_text() {\n let text =\n r#\"{\"choices\":[{\"finish_reason\":\"stop\",\"message\":{\"output_text\":\"{\\\"ok\\\":true}\"}}]}\"#;\n let content = parse_openai_content(text).expect(\"parse output_text\");\n assert_eq!(content, r#\"{\"ok\":true}\"#);\n }\n\n #[test]\n fn parse_openai_content_reads_message_text() {\n let text = r#\"{\"choices\":[{\"finish_reason\":\"stop\",\"message\":{\"text\":\"{\\\"ok\\\":true}\"}}]}\"#;\n let content = parse_openai_content(text).expect(\"parse text\");\n assert_eq!(content, r#\"{\"ok\":true}\"#);\n }\n\n #[test]\n fn parse_openai_content_includes_short_non_json_preview() {\n let text = r#\"{\"choices\":[{\"finish_reason\":\"stop\",\"message\":{\"content\":\"Sure, I can classify that file.\"}}]}\"#;\n let error = parse_openai_content(text).expect_err(\"non-json content should fail\");\n assert!(error.contains(\"AI provider returned non-JSON message content.\"));\n assert!(error.contains(\"content_preview=\\\"Sure, I can classify that file.\\\"\"));\n assert!(error.contains(\"message_keys=[content]\"));\n }\n\n #[test]\n fn provider_error_redaction_removes_api_key_from_summary() {\n let api_key = \"sk-provider-secret\";\n let text = r#\"{\"choices\":[{\"finish_reason\":\"stop\",\"message\":{\"content\":\"Provider rejected sk-provider-secret\"}}]}\"#;\n let error = parse_openai_content(text).expect_err(\"non-json content should fail\");\n let redacted = redact_api_key(&error, api_key);\n assert!(!redacted.contains(api_key));\n assert!(redacted.contains(\"[redacted]\"));\n }\n}\n"}, "96eb19599b09179faf664020b53ec38113c1c269": {"t": 1785757175.3852222, "n": 18640, "h": "2787fa81024bb835", "c": "use std::time::Duration;\n\nuse reqwest::blocking::Client;\nuse serde_json::{json, Map, Value};\n\nuse super::{\n presets::{provider_preset, AIProviderPreset},\n provider::{AIProvider, AIProviderError},\n schema::{AIChatMessage, AIChatRequest, AIConnectionTestResult, AIProviderPresetId},\n settings::AISettings,\n};\n\npub struct OpenAICompatibleProvider {\n settings: AISettings,\n preset: AIProviderPreset,\n}\n\nimpl OpenAICompatibleProvider {\n pub fn new(settings: AISettings) -> Self {\n let preset = provider_preset(settings.preset)\n .or_else(|| provider_preset(AIProviderPresetId::CustomOpenAICompatible))\n .expect(\"custom OpenAI-compatible preset exists\");\n Self { settings, preset }\n }\n\n fn request_url(&self) -> Result<String, AIProviderError> {\n join_base_url_and_chat_path(&self.settings.base_url, &self.settings.chat_path)\n }\n\n fn client(&self) -> Result<Client, AIProviderError> {\n Client::builder()\n .timeout(Duration::from_secs(self.settings.timeout_seconds.max(1)))\n .build()\n .map_err(|error| self.error(format!(\"failed to build AI HTTP client: {error}\")))\n }\n\n fn error(&self, message: impl Into<String>) -> AIProviderError {\n AIProviderError::new(redact_api_key(&message.into(), &self.settings.api_key))\n }\n}\n\nimpl AIProvider for OpenAICompatibleProvider {\n fn chat_json(&self, request: AIChatRequest) -> Result<String, AIProviderError> {\n let url = self.request_url()?;\n let mut body = Map::new();\n body.insert(\"model\".to_string(), json!(request.model));\n body.insert(\n \"messages\".to_string(),\n json!(messages_with_instructions(\n &request.messages,\n request.force_json,\n thinking_enabled(&self.settings, &request),\n self.preset.id\n )),\n );\n body.insert(\"temperature\".to_string(), json!(request.temperature));\n body.insert(\"max_tokens\".to_string(), json!(request.max_tokens));\n\n let response_format_enabled = request\n .provider_options\n .use_response_format\n .unwrap_or(self.preset.supports_response_format);\n if request.force_json && response_format_enabled {\n body.insert(\n \"response_format\".to_string(),\n json!({ \"type\": \"json_object\" }),\n );\n }\n\n // Advanced override: user-provided extra_body_json is merged last and can override\n // provider fields intentionally. The settings UI labels this as an advanced option.\n merge_extra_body(\n &mut body,\n self.settings.extra_body_json.as_deref(),\n &self.settings.api_key,\n )\n .map_err(|error| self.error(error.to_string()))?;\n merge_extra_body(\n &mut body,\n request.provider_options.extra_body_json.as_deref(),\n &self.settings.api_key,\n )\n .map_err(|error| self.error(error.to_string()))?;\n\n let enable_thinking = thinking_enabled(&self.settings, &request);\n if enable_thinking && self.preset.supports_reasoning_effort {\n if let Some(reasoning_effort) = request\n .provider_options\n .reasoning_effort\n .as_deref()\n .or(self.settings.reasoning_effort.as_deref())\n .filter(|value| !value.trim().is_empty())\n {\n body.insert(\"reasoning_effort\".to_string(), json!(reasoning_effort));\n }\n }\n if self.preset.id == AIProviderPresetId::DeepSeek\n && self.preset.supports_thinking\n && !body.contains_key(\"thinking\")\n {\n body.insert(\n \"thinking\".to_string(),\n json!({ \"type\": if enable_thinking { \"enabled\" } else { \"disabled\" } }),\n );\n }\n\n let mut builder = self\n .client()?\n .post(url)\n .header(reqwest::header::CONTENT_TYPE, \"application/json\");\n if !self.settings.api_key.trim().is_empty() {\n builder = builder.bearer_auth(self.settings.api_key.trim());\n }\n\n let response = builder\n .json(&Value::Object(body))\n .send()\n .map_err(|error| self.error(format!(\"AI request failed: {error}\")))?;\n let status = response.status();\n let text = response\n .text()\n .map_err(|error| self.error(format!(\"failed to read AI response: {error}\")))?;\n if !status.is_success() {\n let mut message = format!(\n \"AI provider returned HTTP {status}: {}\",\n short_response(&text)\n );\n if self.preset.id == AIProviderPresetId::DeepSeek\n && !thinking_enabled(&self.settings, &request)\n {\n message.push_str(\n \" If DeepSeek rejects thinking disabled, use a non-thinking model or a compatible legacy model name.\",\n );\n }\n return Err(self.error(message));\n }\n\n parse_openai_content(&text).map_err(|error| self.error(error))\n }\n\n fn test_connection(&self) -> Result<AIConnectionTestResult, AIProviderError> {\n let mut prompt = \"Return exactly this JSON and nothing else: {\\\"ok\\\":true}\\nDo not output Markdown.\\nDo not output reasoning.\\nDo not output <think>.\\nDo not explain.\".to_string();\n if self.preset.id == AIProviderPresetId::DeepSeek && !self.settings.enable_thinking {\n prompt.push_str(\"\\nUse non-thinking mode and only return final content.\");\n }\n let test_max_tokens = self.settings.max_tokens.max(512).min(4096);\n let content = self.chat_json(AIChatRequest {\n messages: vec![AIChatMessage {\n role: \"user\".to_string(),\n content: prompt,\n }],\n model: self.settings.model.clone(),\n temperature: 0.0,\n max_tokens: test_max_tokens,\n force_json: true,\n provider_options: Default::default(),\n })?;\n Ok(AIConnectionTestResult {\n ok: true,\n message: format!(\"AI provider responded: {}\", short_response(&content)),\n model: Some(self.settings.model.clone()),\n provider: Some(self.settings.provider),\n preset: Some(self.settings.preset),\n elapsed_ms: 0,\n })\n }\n}\n\npub fn join_base_url_and_chat_path(\n base_url: &str,\n chat_path: &str,\n) -> Result<String, AIProviderError> {\n let base = base_url.trim().trim_end_matches('/');\n let path = chat_path.trim().trim_start_matches('/');\n if base.is_empty() {\n return Err(AIProviderError::new(\"AI provider base_url is empty.\"));\n }\n if path.is_empty() {\n return Err(AIProviderError::new(\"AI provider chat_path is empty.\"));\n }\n Ok(format!(\"{base}/{path}\"))\n}\n\nfn messages_with_instructions(\n messages: &[AIChatMessage],\n force_json: bool,\n enable_thinking: bool,\n preset_id: AIProviderPresetId,\n) -> Vec<AIChatMessage> {\n let mut output = Vec::new();\n let mut instructions = Vec::new();\n if force_json {\n instructions.push(\"Return only valid JSON. Do not wrap the JSON in markdown.\".to_string());\n }\n if !enable_thinking {\n instructions.push(\"Do not output thinking, reasoning traces, or explanations.\".to_string());\n }\n if preset_id == AIProviderPresetId::DeepSeek {\n instructions.push(\n \"DeepSeek legacy model names deepseek-chat and deepseek-reasoner may be deprecated; keep output compatible with the selected model.\".to_string(),\n );\n }\n if !instructions.is_empty() {\n output.push(AIChatMessage {\n role: \"system\".to_string(),\n content: instructions.join(\" \"),\n });\n }\n output.extend_from_slice(messages);\n output\n}\n\nfn thinking_enabled(settings: &AISettings, request: &AIChatRequest) -> bool {\n request\n .provider_options\n .enable_thinking\n .unwrap_or(settings.enable_thinking)\n}\n\nfn merge_extra_body(\n body: &mut Map<String, Value>,\n extra_body_json: Option<&str>,\n api_key: &str,\n) -> Result<(), AIProviderError> {\n let Some(extra_body_json) = extra_body_json.filter(|value| !value.trim().is_empty()) else {\n return Ok(());\n };\n let value = serde_json::from_str::<Value>(extra_body_json).map_err(|error| {\n AIProviderError::new(redact_api_key(\n &format!(\"AI extra_body_json must be a JSON object: {error}\"),\n api_key,\n ))\n })?;\n let Value::Object(extra) = value else {\n return Err(AIProviderError::new(\n \"AI extra_body_json must be a JSON object.\",\n ));\n };\n for (key, value) in extra {\n body.insert(key, value);\n }\n Ok(())\n}\n\nfn parse_openai_content(text: &str) -> Result<String, String> {\n let value = serde_json::from_str::<Value>(text)\n .map_err(|error| format!(\"AI provider returned invalid JSON: {error}\"))?;\n let summary = summarize_provider_response(&value);\n let Some(choice) = value\n .get(\"choices\")\n .and_then(Value::as_array)\n .and_then(|choices| choices.first())\n else {\n return Err(format!(\n \"AI provider response did not contain choices[0]. {summary}\"\n ));\n };\n let Some(message) = choice.get(\"message\").and_then(Value::as_object) else {\n return Err(format!(\n \"AI provider response did not contain choices[0].message. {summary}\"\n ));\n };\n\n if let Some(content) = message.get(\"content\") {\n let content = content_value_to_text(content).ok_or_else(|| {\n format!(\"AI provider response had unsupported message.content shape. {summary}\")\n })?;\n if content.trim().is_empty() {\n if value_text_len(message.get(\"reasoning_content\")) > 0 {\n if choice.get(\"finish_reason\").and_then(Value::as_str) == Some(\"length\") {\n return Err(format!(\n \"AI provider returned reasoning_content but empty content. 模型只返回了 reasoning_content,且因为输出长度限制被截断,没有生成最终 content。请关闭 Thinking,并提高连接测试 max_tokens;Zen Canvas 已不再使用 64 token 进行连接测试。 {summary}\"\n ));\n }\n return Err(format!(\n \"AI provider returned reasoning_content but empty content. Please disable Thinking or use a non-thinking model. {summary}\"\n ));\n }\n return Err(format!(\n \"AI provider returned empty message.content. {summary}\"\n ));\n }\n return validate_content_has_jsonish_text(content, &summary);\n }\n\n for fallback_key in [\"output_text\", \"text\"] {\n if let Some(content) = message.get(fallback_key) {\n let content = content_value_to_text(content).ok_or_else(|| {\n format!(\n \"AI provider response had unsupported message.{fallback_key} shape. {summary}\"\n )\n })?;\n if content.trim().is_empty() {\n continue;\n }\n return validate_content_has_jsonish_text(content, &summary);\n }\n }\n\n Err(format!(\n \"AI provider response did not contain message.content, message.output_text, or message.text. {summary}\"\n ))\n}\n\npub(crate) fn summarize_provider_response(value: &Value) -> String {\n let choices = value.get(\"choices\").and_then(Value::as_array);\n let choice = choices.and_then(|items| items.first());\n let message = choice\n .and_then(|choice| choice.get(\"message\"))\n .and_then(Value::as_object);\n let mut message_keys = message\n .map(|message| message.keys().cloned().collect::<Vec<_>>())\n .unwrap_or_default();\n message_keys.sort();\n let content = message.and_then(|message| message.get(\"content\"));\n let reasoning_content = message.and_then(|message| message.get(\"reasoning_content\"));\n format!(\n \"provider response summary: has_choices={}; choice_count={}; finish_reason={}; message_keys={}; content_type={}; content_length={}; has_reasoning_content={}; reasoning_content_length={}\",\n choices.is_some(),\n choices.map_or(0, Vec::len),\n choice\n .and_then(|choice| choice.get(\"finish_reason\"))\n .map(json_type_or_string)\n .unwrap_or_else(|| \"missing\".to_string()),\n if message_keys.is_empty() {\n \"[]\".to_string()\n } else {\n format!(\"[{}]\", message_keys.join(\",\"))\n },\n content.map(json_type_name).unwrap_or(\"missing\"),\n value_text_len(content),\n reasoning_content.is_some(),\n value_text_len(reasoning_content),\n )\n}\n\nfn content_value_to_text(value: &Value) -> Option<String> {\n match value {\n Value::String(text) => Some(text.clone()),\n Value::Array(items) => {\n let mut output = String::new();\n for item in items {\n let Some(object) = item.as_object() else {\n continue;\n };\n if object.get(\"type\").and_then(Value::as_str) == Some(\"text\") {\n if let Some(text) = object.get(\"text\").and_then(Value::as_str) {\n output.push_str(text);\n }\n }\n }\n Some(output)\n }\n _ => None,\n }\n}\n\nfn validate_content_has_jsonish_text(content: String, summary: &str) -> Result<String, String> {\n if content.contains('{') || content.contains('[') {\n Ok(content)\n } else {\n Err(format!(\n \"AI provider returned non-JSON message content. content_preview=\\\"{}\\\". {summary}\",\n preview_text(&content, 300)\n ))\n }\n}\n\nfn value_text_len(value: Option<&Value>) -> usize {\n value\n .and_then(content_value_to_text)\n .map(|text| text.chars().count())\n .unwrap_or(0)\n}\n\nfn json_type_or_string(value: &Value) -> String {\n value\n .as_str()\n .map(ToString::to_string)\n .unwrap_or_else(|| json_type_name(value).to_string())\n}\n\nfn json_type_name(value: &Value) -> &'static str {\n match value {\n Value::Null => \"null\",\n Value::Bool(_) => \"boolean\",\n Value::Number(_) => \"number\",\n Value::String(_) => \"string\",\n Value::Array(_) => \"array\",\n Value::Object(_) => \"object\",\n }\n}\n\nfn preview_text(text: &str, limit: usize) -> String {\n let mut preview = text\n .chars()\n .take(limit)\n .collect::<String>()\n .replace(['\\r', '\\n', '\\t'], \" \");\n if text.chars().count() > limit {\n preview.push_str(\"...\");\n }\n preview\n}\n\nfn short_response(text: &str) -> String {\n const LIMIT: usize = 500;\n if text.len() <= LIMIT {\n text.to_string()\n } else {\n format!(\"{}...\", &text[..LIMIT])\n }\n}\n\nfn redact_api_key(message: &str, api_key: &str) -> String {\n let trimmed = api_key.trim();\n if trimmed.is_empty() {\n message.to_string()\n } else {\n message.replace(trimmed, \"[redacted]\")\n }\n}\n\n#[cfg(test)]\nmod tests {\n use super::*;\n\n #[test]\n fn parse_openai_content_reads_normal_string_content() {\n let text =\n r#\"{\"choices\":[{\"finish_reason\":\"stop\",\"message\":{\"content\":\"{\\\"ok\\\":true}\"}}]}\"#;\n let content = parse_openai_content(text).expect(\"parse content\");\n assert_eq!(content, r#\"{\"ok\":true}\"#);\n }\n\n #[test]\n fn parse_openai_content_reports_empty_content() {\n let text = r#\"{\"choices\":[{\"finish_reason\":\"stop\",\"message\":{\"content\":\" \"}}]}\"#;\n let error = parse_openai_content(text).expect_err(\"empty content should fail\");\n assert!(error.contains(\"AI provider returned empty message.content.\"));\n assert!(error.contains(\"has_choices=true\"));\n assert!(error.contains(\"content_type=string\"));\n assert!(error.contains(\"content_length=2\"));\n }\n\n #[test]\n fn parse_openai_content_reports_reasoning_without_final_content() {\n let text = r#\"{\"choices\":[{\"finish_reason\":\"stop\",\"message\":{\"content\":\"\",\"reasoning_content\":\"thinking trace\"}}]}\"#;\n let error = parse_openai_content(text).expect_err(\"reasoning-only response should fail\");\n assert!(error.contains(\"AI provider returned reasoning_content but empty content.\"));\n assert!(error.contains(\"Please disable Thinking or use a non-thinking model.\"));\n assert!(error.contains(\"has_reasoning_content=true\"));\n assert!(error.contains(\"reasoning_content_length=14\"));\n }\n\n #[test]\n fn parse_openai_content_reads_array_text_content() {\n let text = r#\"{\"choices\":[{\"finish_reason\":\"stop\",\"message\":{\"content\":[{\"type\":\"text\",\"text\":\"{\\\"ok\\\":\"},{\"type\":\"text\",\"text\":\"true}\"}]}}]}\"#;\n let content = parse_openai_content(text).expect(\"parse text parts\");\n assert_eq!(content, r#\"{\"ok\":true}\"#);\n }\n\n #[test]\n fn parse_openai_content_reads_message_output_text() {\n let text =\n r#\"{\"choices\":[{\"finish_reason\":\"stop\",\"message\":{\"output_text\":\"{\\\"ok\\\":true}\"}}]}\"#;\n let content = parse_openai_content(text).expect(\"parse output_text\");\n assert_eq!(content, r#\"{\"ok\":true}\"#);\n }\n\n #[test]\n fn parse_openai_content_reads_message_text() {\n let text = r#\"{\"choices\":[{\"finish_reason\":\"stop\",\"message\":{\"text\":\"{\\\"ok\\\":true}\"}}]}\"#;\n let content = parse_openai_content(text).expect(\"parse text\");\n assert_eq!(content, r#\"{\"ok\":true}\"#);\n }\n\n #[test]\n fn parse_openai_content_includes_short_non_json_preview() {\n let text = r#\"{\"choices\":[{\"finish_reason\":\"stop\",\"message\":{\"content\":\"Sure, I can classify that file.\"}}]}\"#;\n let error = parse_openai_content(text).expect_err(\"non-json content should fail\");\n assert!(error.contains(\"AI provider returned non-JSON message content.\"));\n assert!(error.contains(\"content_preview=\\\"Sure, I can classify that file.\\\"\"));\n assert!(error.contains(\"message_keys=[content]\"));\n }\n\n #[test]\n fn provider_error_redaction_removes_api_key_from_summary() {\n let api_key = \"sk-provider-secret\";\n let text = r#\"{\"choices\":[{\"finish_reason\":\"stop\",\"message\":{\"content\":\"Provider rejected sk-provider-secret\"}}]}\"#;\n let error = parse_openai_content(text).expect_err(\"non-json content should fail\");\n let redacted = redact_api_key(&error, api_key);\n assert!(!redacted.contains(api_key));\n assert!(redacted.contains(\"[redacted]\"));\n }\n}\n"}, "4cb61ac6a97b041f24648e50a23cea3a2e3599dd": {"t": 1785757176.846023, "n": 9175, "h": "d20c296dee4216aa", "c": "use std::time::Duration;\n\nuse reqwest::blocking::Client;\nuse serde_json::{json, Map, Value};\n\nuse super::{\n presets::{provider_preset, AIExtraBodyStrategy, AIProviderPreset},\n provider::{AIProvider, AIProviderError},\n schema::{AIChatMessage, AIChatRequest, AIConnectionTestResult, AIProviderPresetId},\n settings::AISettings,\n};\n\npub struct OpenAICompatibleProvider {\n settings: AISettings,\n preset: AIProviderPreset,\n}\n\nimpl OpenAICompatibleProvider {\n pub fn new(settings: AISettings) -> Self {\n let preset = provider_preset(settings.preset)\n .or_else(|| provider_preset(AIProviderPresetId::CustomOpenAICompatible))\n .expect(\"custom OpenAI-compatible preset exists\");\n Self { settings, preset }\n }\n\n fn request_url(&self) -> Result<String, AIProviderError> {\n join_base_url_and_chat_path(&self.settings.base_url, &self.settings.chat_path)\n }\n\n fn client(&self) -> Result<Client, AIProviderError> {\n Client::builder()\n .timeout(Duration::from_secs(self.settings.timeout_seconds.max(1)))\n .build()\n .map_err(|error| self.error(format!(\"failed to build AI HTTP client: {error}\")))\n }\n\n fn error(&self, message: impl Into<String>) -> AIProviderError {\n AIProviderError::new(redact_api_key(&message.into(), &self.settings.api_key))\n }\n}\n\nimpl AIProvider for OpenAICompatibleProvider {\n fn chat_json(&self, request: AIChatRequest) -> Result<String, AIProviderError> {\n let url = self.request_url()?;\n let mut body = Map::new();\n body.insert(\"model\".to_string(), json!(request.model));\n body.insert(\n \"messages\".to_string(),\n json!(messages_with_instructions(\n &request.messages,\n request.force_json,\n thinking_enabled(&self.settings, &request),\n self.preset.id\n )),\n );\n body.insert(\"temperature\".to_string(), json!(request.temperature));\n body.insert(\"max_tokens\".to_string(), json!(request.max_tokens));\n\n let response_format_enabled = request\n .provider_options\n .use_response_format\n .unwrap_or(self.preset.supports_response_format);\n if request.force_json && response_format_enabled {\n body.insert(\n \"response_format\".to_string(),\n json!({ \"type\": \"json_object\" }),\n );\n }\n\n // Advanced override: user-provided extra_body_json is merged last and can override\n // provider fields intentionally. The settings UI labels this as an advanced option.\n merge_extra_body(\n &mut body,\n self.settings.extra_body_json.as_deref(),\n &self.settings.api_key,\n )\n .map_err(|error| self.error(error.to_string()))?;\n merge_extra_body(\n &mut body,\n request.provider_options.extra_body_json.as_deref(),\n &self.settings.api_key,\n )\n .map_err(|error| self.error(error.to_string()))?;\n\n let enable_thinking = thinking_enabled(&self.settings, &request);\n if enable_thinking && self.preset.supports_reasoning_effort {\n if let Some(reasoning_effort) = request\n .provider_options\n .reasoning_effort\n .as_deref()\n .or(self.settings.reasoning_effort.as_deref())\n .filter(|value| !value.trim().is_empty())\n {\n body.insert(\"reasoning_effort\".to_string(), json!(reasoning_effort));\n }\n }\n if enable_thinking\n && self.preset.supports_thinking\n && self.preset.extra_body_strategy == AIExtraBodyStrategy::DeepSeekThinking\n && !body.contains_key(\"thinking\")\n {\n body.insert(\"thinking\".to_string(), json!({ \"type\": \"enabled\" }));\n }\n\n let mut builder = self\n .client()?\n .post(url)\n .header(reqwest::header::CONTENT_TYPE, \"application/json\");\n if !self.settings.api_key.trim().is_empty() {\n builder = builder.bearer_auth(self.settings.api_key.trim());\n }\n\n let response = builder\n .json(&Value::Object(body))\n .send()\n .map_err(|error| self.error(format!(\"AI request failed: {error}\")))?;\n let status = response.status();\n let text = response\n .text()\n .map_err(|error| self.error(format!(\"failed to read AI response: {error}\")))?;\n if !status.is_success() {\n return Err(self.error(format!(\n \"AI provider returned HTTP {status}: {}\",\n short_response(&text)\n )));\n }\n\n parse_openai_content(&text).map_err(|error| self.error(error))\n }\n\n fn test_connection(&self) -> Result<AIConnectionTestResult, AIProviderError> {\n let content = self.chat_json(AIChatRequest {\n messages: vec![AIChatMessage {\n role: \"user\".to_string(),\n content: \"Return {\\\"ok\\\":true} as JSON.\".to_string(),\n }],\n model: self.settings.model.clone(),\n temperature: 0.0,\n max_tokens: 64,\n force_json: true,\n provider_options: Default::default(),\n })?;\n Ok(AIConnectionTestResult {\n ok: true,\n message: format!(\"AI provider responded: {}\", short_response(&content)),\n model: Some(self.settings.model.clone()),\n provider: Some(self.settings.provider),\n preset: Some(self.settings.preset),\n elapsed_ms: 0,\n })\n }\n}\n\npub fn join_base_url_and_chat_path(\n base_url: &str,\n chat_path: &str,\n) -> Result<String, AIProviderError> {\n let base = base_url.trim().trim_end_matches('/');\n let path = chat_path.trim().trim_start_matches('/');\n if base.is_empty() {\n return Err(AIProviderError::new(\"AI provider base_url is empty.\"));\n }\n if path.is_empty() {\n return Err(AIProviderError::new(\"AI provider chat_path is empty.\"));\n }\n Ok(format!(\"{base}/{path}\"))\n}\n\nfn messages_with_instructions(\n messages: &[AIChatMessage],\n force_json: bool,\n enable_thinking: bool,\n preset_id: AIProviderPresetId,\n) -> Vec<AIChatMessage> {\n let mut output = Vec::new();\n let mut instructions = Vec::new();\n if force_json {\n instructions.push(\"Return only valid JSON. Do not wrap the JSON in markdown.\".to_string());\n }\n if !enable_thinking {\n instructions.push(\"Do not output thinking, reasoning traces, or explanations.\".to_string());\n }\n if preset_id == AIProviderPresetId::DeepSeek {\n instructions.push(\n \"DeepSeek legacy model names deepseek-chat and deepseek-reasoner may be deprecated; keep output compatible with the selected model.\".to_string(),\n );\n }\n if !instructions.is_empty() {\n output.push(AIChatMessage {\n role: \"system\".to_string(),\n content: instructions.join(\" \"),\n });\n }\n output.extend_from_slice(messages);\n output\n}\n\nfn thinking_enabled(settings: &AISettings, request: &AIChatRequest) -> bool {\n request\n .provider_options\n .enable_thinking\n .unwrap_or(settings.enable_thinking)\n}\n\nfn merge_extra_body(\n body: &mut Map<String, Value>,\n extra_body_json: Option<&str>,\n api_key: &str,\n) -> Result<(), AIProviderError> {\n let Some(extra_body_json) = extra_body_json.filter(|value| !value.trim().is_empty()) else {\n return Ok(());\n };\n let value = serde_json::from_str::<Value>(extra_body_json).map_err(|error| {\n AIProviderError::new(redact_api_key(\n &format!(\"AI extra_body_json must be a JSON object: {error}\"),\n api_key,\n ))\n })?;\n let Value::Object(extra) = value else {\n return Err(AIProviderError::new(\n \"AI extra_body_json must be a JSON object.\",\n ));\n };\n for (key, value) in extra {\n body.insert(key, value);\n }\n Ok(())\n}\n\nfn parse_openai_content(text: &str) -> Result<String, String> {\n let value = serde_json::from_str::<Value>(text)\n .map_err(|error| format!(\"AI provider returned invalid JSON: {error}\"))?;\n value\n .get(\"choices\")\n .and_then(Value::as_array)\n .and_then(|choices| choices.first())\n .and_then(|choice| choice.get(\"message\"))\n .and_then(|message| message.get(\"content\"))\n .and_then(Value::as_str)\n .map(ToString::to_string)\n .ok_or_else(|| {\n format!(\n \"AI provider response did not contain choices[0].message.content: {}\",\n short_response(text)\n )\n })\n}\n\nfn short_response(text: &str) -> String {\n const LIMIT: usize = 500;\n if text.len() <= LIMIT {\n text.to_string()\n } else {\n format!(\"{}...\", &text[..LIMIT])\n }\n}\n\nfn redact_api_key(message: &str, api_key: &str) -> String {\n let trimmed = api_key.trim();\n if trimmed.is_empty() {\n message.to_string()\n } else {\n message.replace(trimmed, \"[redacted]\")\n }\n}\n"}}}} |