Files
hack/tools/scripts/llm-key-hunter/results/_cache/content/ardenzc_zen-canvas.json
T

1 line
342 KiB
JSON
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
{"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"}}}}