Local branch had diverged from origin/master (sibling commits on the same base). Rewrote local history linearly on top of origin/master, folding in all local content: exa/hot-platform discovery hunters, leaks ledger, nightly sweep orchestrator, updated .gitignore and skill docs, plus the latest hunt output and vault state. Remote-only files (vault keys, channel scripts) were restored rather than dropped, so the resulting tree is a full union of both sides.
1 line
468 KiB
JSON
1 line
468 KiB
JSON
{"repo": "ardenzc/zen-canvas", "files": {"src-tauri/src/ai/registry.rs": {"9ea69d29143b994c8632747ab647f59637dfe324": {"t": 1785745843.540229, "n": 23417, "h": "1f108b13a19604d0", "c": "use serde::{Deserialize, Serialize};\n\nuse super::schema::{AIProviderKind, AIProviderPresetId};\n\n#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]\n#[serde(rename_all = \"snake_case\")]\npub enum AIAuthKind {\n None,\n BearerApiKey,\n ApiKeyHeader,\n QianfanAkSk,\n}\n\n#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]\n#[serde(rename_all = \"snake_case\")]\npub enum AITokenParameter {\n MaxTokens,\n MaxCompletionTokens,\n}\n\n#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]\n#[serde(rename_all = \"snake_case\")]\npub enum AIThinkingStrategy {\n None,\n DeepSeekThinkingObject,\n GenericThinkingObject,\n EnableThinkingBoolean,\n ReasoningEffort,\n MiniMaxReasoningSplit,\n PromptOnly,\n}\n\n#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]\n#[serde(rename_all = \"snake_case\")]\npub enum ResponseFieldPath {\n ChoicesMessageContent,\n ChoicesMessageOutputText,\n ChoicesMessageText,\n ChoicesMessageReasoningContent,\n ChoicesMessageReasoningDetails,\n RootOutputText,\n RootContent,\n}\n\n#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]\n#[serde(rename_all = \"camelCase\")]\npub struct AIProviderCapabilities {\n pub supports_model_discovery: bool,\n pub supports_response_format_json_object: bool,\n pub supports_json_schema: bool,\n pub supports_thinking: bool,\n pub supports_thinking_toggle: bool,\n pub supports_reasoning_effort: bool,\n pub supports_usage: bool,\n pub supports_streaming: bool,\n}\n\n#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]\n#[serde(rename_all = \"camelCase\")]\npub struct AIParameterProfile {\n pub temperature_min: f64,\n pub temperature_max: f64,\n pub default_temperature: f64,\n pub max_output_tokens: Option<u32>,\n pub token_parameter: AITokenParameter,\n pub thinking_strategy: AIThinkingStrategy,\n}\n\n#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]\n#[serde(rename_all = \"camelCase\")]\npub struct AIResponseProfile {\n pub content_paths: &'static [ResponseFieldPath],\n pub reasoning_paths: &'static [ResponseFieldPath],\n pub finish_reason_paths: &'static [ResponseFieldPath],\n pub usage_paths: &'static [ResponseFieldPath],\n pub strip_think_tags: bool,\n}\n\n#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]\n#[serde(rename_all = \"camelCase\")]\npub struct AIEndpointVariant {\n pub id: &'static str,\n pub label: &'static str,\n pub base_url: &'static str,\n}\n\n#[derive(Debug, Clone, Copy, PartialEq, Serialize)]\n#[serde(rename_all = \"camelCase\")]\npub struct AIProviderDescriptor {\n pub id: AIProviderPresetId,\n pub label: &'static str,\n pub provider_kind: AIProviderKind,\n pub default_base_url: &'static str,\n pub default_chat_path: &'static str,\n pub models_path: Option<&'static str>,\n pub default_model: &'static str,\n pub suggested_models: &'static [&'static str],\n pub auth_kind: AIAuthKind,\n pub capabilities: AIProviderCapabilities,\n pub parameter_profile: AIParameterProfile,\n pub response_profile: AIResponseProfile,\n pub endpoint_variants: &'static [AIEndpointVariant],\n pub api_key_env_hint: &'static str,\n pub docs_url: Option<&'static str>,\n}\n\nconst OPENAI_CONTENT_PATHS: &[ResponseFieldPath] = &[\n ResponseFieldPath::ChoicesMessageContent,\n ResponseFieldPath::ChoicesMessageOutputText,\n ResponseFieldPath::ChoicesMessageText,\n ResponseFieldPath::RootOutputText,\n ResponseFieldPath::RootContent,\n];\nconst OPENAI_REASONING_PATHS: &[ResponseFieldPath] = &[\n ResponseFieldPath::ChoicesMessageReasoningContent,\n ResponseFieldPath::ChoicesMessageReasoningDetails,\n];\nconst OPENAI_FINISH_PATHS: &[ResponseFieldPath] = &[];\nconst OPENAI_USAGE_PATHS: &[ResponseFieldPath] = &[];\nconst EMPTY_ENDPOINTS: &[AIEndpointVariant] = &[];\nconst DEEPSEEK_MODELS: &[&str] = &[\"deepseek-v4-flash\", \"deepseek-v4-pro\"];\nconst KIMI_MODELS: &[&str] = &[\"kimi-k2.6\", \"kimi-k2-thinking\", \"moonshot-v1-128k\"];\nconst QWEN_MODELS: &[&str] = &[\"qwen-plus\", \"qwen3.6-plus\", \"qwen3.5-plus\", \"qwen3-vl-plus\"];\nconst GLM_MODELS: &[&str] = &[\"glm-5.2\", \"glm-5\", \"glm-4.7\", \"glm-4.5\"];\nconst MINIMAX_MODELS: &[&str] = &[\"MiniMax-M2.5\", \"MiniMax-M2.1\", \"MiniMax-M2\"];\nconst DOUBAO_MODELS: &[&str] = &[\"ep-2024-...\", \"doubao-seed-1-6-250615\"];\nconst HUNYUAN_MODELS: &[&str] = &[\"hunyuan-turbos\", \"hunyuan-pro\"];\nconst STEPFUN_MODELS: &[&str] = &[\"step-3.5-flash\", \"step-3.5-turbo\"];\nconst OLLAMA_MODELS: &[&str] = &[\"qwen3:8b\", \"llama3.2:3b\"];\n\nconst DASHSCOPE_ENDPOINTS: &[AIEndpointVariant] = &[\n AIEndpointVariant {\n id: \"cn-beijing\",\n label: \"中国内地(北京)\",\n base_url: \"https://dashscope.aliyuncs.com/compatible-mode/v1\",\n },\n AIEndpointVariant {\n id: \"us-virginia\",\n label: \"美国(弗吉尼亚)\",\n base_url: \"https://dashscope-us.aliyuncs.com/compatible-mode/v1\",\n },\n AIEndpointVariant {\n id: \"singapore-workspace\",\n label: \"新加坡(Workspace Endpoint)\",\n base_url: \"https://{WorkspaceId}.ap-southeast-1.maas.aliyuncs.com/compatible-mode/v1\",\n },\n AIEndpointVariant {\n id: \"japan-workspace\",\n label: \"日本(Workspace Endpoint)\",\n base_url: \"https://{WorkspaceId}.ap-northeast-1.maas.aliyuncs.com/compatible-mode/v1\",\n },\n];\n\nconst OPENAI_RESPONSE_PROFILE: AIResponseProfile = AIResponseProfile {\n content_paths: OPENAI_CONTENT_PATHS,\n reasoning_paths: OPENAI_REASONING_PATHS,\n finish_reason_paths: OPENAI_FINISH_PATHS,\n usage_paths: OPENAI_USAGE_PATHS,\n strip_think_tags: false,\n};\nconst MINIMAX_RESPONSE_PROFILE: AIResponseProfile = AIResponseProfile {\n content_paths: OPENAI_CONTENT_PATHS,\n reasoning_paths: OPENAI_REASONING_PATHS,\n finish_reason_paths: OPENAI_FINISH_PATHS,\n usage_paths: OPENAI_USAGE_PATHS,\n strip_think_tags: true,\n};\n\nconst PROMPT_ONLY: AIProviderCapabilities = AIProviderCapabilities {\n supports_model_discovery: true,\n supports_response_format_json_object: false,\n supports_json_schema: false,\n supports_thinking: false,\n supports_thinking_toggle: false,\n supports_reasoning_effort: false,\n supports_usage: true,\n supports_streaming: true,\n};\n\nconst DEFAULT_OPENAI_PARAMETERS: AIParameterProfile = AIParameterProfile {\n temperature_min: 0.0,\n temperature_max: 2.0,\n default_temperature: 0.0,\n max_output_tokens: None,\n token_parameter: AITokenParameter::MaxTokens,\n thinking_strategy: AIThinkingStrategy::PromptOnly,\n};\nconst QWEN_CAPABILITIES: AIProviderCapabilities = AIProviderCapabilities {\n supports_model_discovery: true,\n supports_response_format_json_object: true,\n supports_json_schema: false,\n supports_thinking: true,\n supports_thinking_toggle: true,\n supports_reasoning_effort: false,\n supports_usage: true,\n supports_streaming: true,\n};\n\npub static PROVIDER_REGISTRY: &[AIProviderDescriptor] = &[\n AIProviderDescriptor {\n id: AIProviderPresetId::DeepSeek,\n label: \"DeepSeek\",\n provider_kind: AIProviderKind::OpenAICompatible,\n default_base_url: \"https://api.deepseek.com\",\n default_chat_path: \"/chat/completions\",\n models_path: Some(\"/models\"),\n default_model: \"deepseek-v4-flash\",\n suggested_models: DEEPSEEK_MODELS,\n auth_kind: AIAuthKind::BearerApiKey,\n capabilities: AIProviderCapabilities {\n supports_model_discovery: true,\n supports_response_format_json_object: true,\n supports_json_schema: false,\n supports_thinking: true,\n supports_thinking_toggle: true,\n supports_reasoning_effort: true,\n supports_usage: true,\n supports_streaming: true,\n },\n parameter_profile: AIParameterProfile {\n temperature_min: 0.0,\n temperature_max: 2.0,\n default_temperature: 0.0,\n max_output_tokens: Some(8192),\n token_parameter: AITokenParameter::MaxTokens,\n thinking_strategy: AIThinkingStrategy::DeepSeekThinkingObject,\n },\n response_profile: OPENAI_RESPONSE_PROFILE,\n endpoint_variants: EMPTY_ENDPOINTS,\n api_key_env_hint: \"DEEPSEEK_API_KEY\",\n docs_url: Some(\"https://api-docs.deepseek.com/\"),\n },\n AIProviderDescriptor {\n id: AIProviderPresetId::Kimi,\n label: \"Kimi / Moonshot\",\n provider_kind: AIProviderKind::OpenAICompatible,\n default_base_url: \"https://api.moonshot.ai/v1\",\n default_chat_path: \"/chat/completions\",\n models_path: Some(\"/models\"),\n default_model: \"kimi-k2.6\",\n suggested_models: KIMI_MODELS,\n auth_kind: AIAuthKind::BearerApiKey,\n capabilities: AIProviderCapabilities {\n supports_model_discovery: true,\n supports_response_format_json_object: true,\n supports_json_schema: false,\n supports_thinking: true,\n supports_thinking_toggle: true,\n supports_reasoning_effort: false,\n supports_usage: true,\n supports_streaming: true,\n },\n parameter_profile: AIParameterProfile {\n temperature_min: 0.0,\n temperature_max: 1.0,\n default_temperature: 0.0,\n max_output_tokens: Some(32768),\n token_parameter: AITokenParameter::MaxTokens,\n thinking_strategy: AIThinkingStrategy::GenericThinkingObject,\n },\n response_profile: OPENAI_RESPONSE_PROFILE,\n endpoint_variants: EMPTY_ENDPOINTS,\n api_key_env_hint: \"MOONSHOT_API_KEY\",\n docs_url: Some(\"https://platform.kimi.ai/docs\"),\n },\n AIProviderDescriptor {\n id: AIProviderPresetId::QwenDashScope,\n label: \"Qwen / 阿里云百炼\",\n provider_kind: AIProviderKind::OpenAICompatible,\n default_base_url: \"https://dashscope.aliyuncs.com/compatible-mode/v1\",\n default_chat_path: \"/chat/completions\",\n models_path: Some(\"/models\"),\n default_model: \"qwen-plus\",\n suggested_models: QWEN_MODELS,\n auth_kind: AIAuthKind::BearerApiKey,\n capabilities: QWEN_CAPABILITIES,\n parameter_profile: AIParameterProfile {\n temperature_min: 0.0,\n temperature_max: 2.0,\n default_temperature: 0.0,\n max_output_tokens: Some(32768),\n token_parameter: AITokenParameter::MaxTokens,\n thinking_strategy: AIThinkingStrategy::EnableThinkingBoolean,\n },\n response_profile: OPENAI_RESPONSE_PROFILE,\n endpoint_variants: DASHSCOPE_ENDPOINTS,\n api_key_env_hint: \"DASHSCOPE_API_KEY\",\n docs_url: Some(\"https://help.aliyun.com/model-studio/\"),\n },\n AIProviderDescriptor {\n id: AIProviderPresetId::ZhipuGlm,\n label: \"智谱 GLM\",\n provider_kind: AIProviderKind::OpenAICompatible,\n default_base_url: \"https://open.bigmodel.cn/api/paas/v4\",\n default_chat_path: \"/chat/completions\",\n models_path: None,\n default_model: \"glm-5.2\",\n suggested_models: GLM_MODELS,\n auth_kind: AIAuthKind::BearerApiKey,\n capabilities: AIProviderCapabilities {\n supports_model_discovery: false,\n supports_response_format_json_object: true,\n supports_json_schema: false,\n supports_thinking: true,\n supports_thinking_toggle: true,\n supports_reasoning_effort: true,\n supports_usage: true,\n supports_streaming: true,\n },\n parameter_profile: AIParameterProfile {\n temperature_min: 0.0,\n temperature_max: 2.0,\n default_temperature: 0.0,\n max_output_tokens: Some(32768),\n token_parameter: AITokenParameter::MaxTokens,\n thinking_strategy: AIThinkingStrategy::GenericThinkingObject,\n },\n response_profile: OPENAI_RESPONSE_PROFILE,\n endpoint_variants: EMPTY_ENDPOINTS,\n api_key_env_hint: \"ZHIPUAI_API_KEY\",\n docs_url: Some(\"https://docs.bigmodel.cn/\"),\n },\n AIProviderDescriptor {\n id: AIProviderPresetId::DoubaoArk,\n label: \"豆包 / 火山方舟\",\n provider_kind: AIProviderKind::OpenAICompatible,\n default_base_url: \"https://ark.cn-beijing.volces.com/api/v3\",\n default_chat_path: \"/chat/completions\",\n models_path: Some(\"/models\"),\n default_model: \"\",\n suggested_models: DOUBAO_MODELS,\n auth_kind: AIAuthKind::BearerApiKey,\n capabilities: PROMPT_ONLY,\n parameter_profile: AIParameterProfile {\n temperature_min: 0.0,\n temperature_max: 2.0,\n default_temperature: 0.0,\n max_output_tokens: Some(32768),\n token_parameter: AITokenParameter::MaxTokens,\n thinking_strategy: AIThinkingStrategy::PromptOnly,\n },\n response_profile: OPENAI_RESPONSE_PROFILE,\n endpoint_variants: EMPTY_ENDPOINTS,\n api_key_env_hint: \"ARK_API_KEY\",\n docs_url: Some(\"https://www.volcengine.com/docs/82379\"),\n },\n AIProviderDescriptor {\n id: AIProviderPresetId::Minimax,\n label: \"MiniMax\",\n provider_kind: AIProviderKind::OpenAICompatible,\n default_base_url: \"https://api.minimaxi.com/v1\",\n default_chat_path: \"/chat/completions\",\n models_path: Some(\"/models\"),\n default_model: \"MiniMax-M2.5\",\n suggested_models: MINIMAX_MODELS,\n auth_kind: AIAuthKind::BearerApiKey,\n capabilities: AIProviderCapabilities {\n supports_model_discovery: true,\n supports_response_format_json_object: true,\n supports_json_schema: false,\n supports_thinking: true,\n supports_thinking_toggle: true,\n supports_reasoning_effort: false,\n supports_usage: true,\n supports_streaming: true,\n },\n parameter_profile: AIParameterProfile {\n temperature_min: 0.0,\n temperature_max: 1.0,\n default_temperature: 0.0,\n max_output_tokens: Some(32768),\n token_parameter: AITokenParameter::MaxCompletionTokens,\n thinking_strategy: AIThinkingStrategy::MiniMaxReasoningSplit,\n },\n response_profile: MINIMAX_RESPONSE_PROFILE,\n endpoint_variants: EMPTY_ENDPOINTS,\n api_key_env_hint: \"MINIMAX_API_KEY\",\n docs_url: Some(\"https://platform.minimaxi.com/document\"),\n },\n AIProviderDescriptor {\n id: AIProviderPresetId::Hunyuan,\n label: \"腾讯混元\",\n provider_kind: AIProviderKind::OpenAICompatible,\n default_base_url: \"https://api.hunyuan.cloud.tencent.com/v1\",\n default_chat_path: \"/chat/completions\",\n models_path: Some(\"/models\"),\n default_model: \"hunyuan-turbos\",\n suggested_models: HUNYUAN_MODELS,\n auth_kind: AIAuthKind::BearerApiKey,\n capabilities: PROMPT_ONLY,\n parameter_profile: DEFAULT_OPENAI_PARAMETERS,\n response_profile: OPENAI_RESPONSE_PROFILE,\n endpoint_variants: EMPTY_ENDPOINTS,\n api_key_env_hint: \"HUNYUAN_API_KEY\",\n docs_url: Some(\"https://cloud.tencent.com/document/product/1729\"),\n },\n AIProviderDescriptor {\n id: AIProviderPresetId::Siliconflow,\n label: \"SiliconFlow / 硅基流动\",\n provider_kind: AIProviderKind::OpenAICompatible,\n default_base_url: \"https://api.siliconflow.cn/v1\",\n default_chat_path: \"/chat/completions\",\n models_path: Some(\"/models\"),\n default_model: \"\",\n suggested_models: &[],\n auth_kind: AIAuthKind::BearerApiKey,\n capabilities: PROMPT_ONLY,\n parameter_profile: DEFAULT_OPENAI_PARAMETERS,\n response_profile: OPENAI_RESPONSE_PROFILE,\n endpoint_variants: EMPTY_ENDPOINTS,\n api_key_env_hint: \"SILICONFLOW_API_KEY\",\n docs_url: Some(\"https://docs.siliconflow.cn/\"),\n },\n AIProviderDescriptor {\n id: AIProviderPresetId::BaiduQianfan,\n label: \"百度千帆 / 文心\",\n provider_kind: AIProviderKind::OpenAICompatible,\n default_base_url: \"https://qianfan.baidubce.com/v2\",\n default_chat_path: \"/chat/completions\",\n models_path: None,\n default_model: \"ernie-4.5-turbo-32k\",\n suggested_models: &[\"ernie-4.5-turbo-32k\", \"ernie-speed-128k\"],\n auth_kind: AIAuthKind::BearerApiKey,\n capabilities: PROMPT_ONLY,\n parameter_profile: DEFAULT_OPENAI_PARAMETERS,\n response_profile: OPENAI_RESPONSE_PROFILE,\n endpoint_variants: EMPTY_ENDPOINTS,\n api_key_env_hint: \"QIANFAN_API_KEY\",\n docs_url: Some(\"https://cloud.baidu.com/doc/WENXINWORKSHOP/\"),\n },\n AIProviderDescriptor {\n id: AIProviderPresetId::Baichuan,\n label: \"百川智能(兼容入口)\",\n provider_kind: AIProviderKind::OpenAICompatible,\n default_base_url: \"\",\n default_chat_path: \"/chat/completions\",\n models_path: None,\n default_model: \"\",\n suggested_models: &[\"Baichuan4-Turbo\"],\n auth_kind: AIAuthKind::BearerApiKey,\n capabilities: PROMPT_ONLY,\n parameter_profile: DEFAULT_OPENAI_PARAMETERS,\n response_profile: OPENAI_RESPONSE_PROFILE,\n endpoint_variants: EMPTY_ENDPOINTS,\n api_key_env_hint: \"BAICHUAN_API_KEY\",\n docs_url: Some(\"https://platform.baichuan-ai.com/docs/api\"),\n },\n AIProviderDescriptor {\n id: AIProviderPresetId::StepFun,\n label: \"阶跃星辰 / StepFun\",\n provider_kind: AIProviderKind::OpenAICompatible,\n default_base_url: \"https://api.stepfun.com/v1\",\n default_chat_path: \"/chat/completions\",\n models_path: Some(\"/models\"),\n default_model: \"step-3.5-flash\",\n suggested_models: STEPFUN_MODELS,\n auth_kind: AIAuthKind::BearerApiKey,\n capabilities: PROMPT_ONLY,\n parameter_profile: DEFAULT_OPENAI_PARAMETERS,\n response_profile: OPENAI_RESPONSE_PROFILE,\n endpoint_variants: EMPTY_ENDPOINTS,\n api_key_env_hint: \"STEPFUN_API_KEY\",\n docs_url: Some(\"https://platform.stepfun.com/docs\"),\n },\n AIProviderDescriptor {\n id: AIProviderPresetId::Yi,\n label: \"零一万物 / Yi(兼容平台)\",\n provider_kind: AIProviderKind::OpenAICompatible,\n default_base_url: \"\",\n default_chat_path: \"/chat/completions\",\n models_path: None,\n default_model: \"\",\n suggested_models: &[\"yi-large\"],\n auth_kind: AIAuthKind::BearerApiKey,\n capabilities: PROMPT_ONLY,\n parameter_profile: DEFAULT_OPENAI_PARAMETERS,\n response_profile: OPENAI_RESPONSE_PROFILE,\n endpoint_variants: EMPTY_ENDPOINTS,\n api_key_env_hint: \"YI_API_KEY\",\n docs_url: Some(\"https://platform.lingyiwanwu.com/\"),\n },\n AIProviderDescriptor {\n id: AIProviderPresetId::CustomOpenAICompatible,\n label: \"自定义 OpenAI-compatible\",\n provider_kind: AIProviderKind::OpenAICompatible,\n default_base_url: \"\",\n default_chat_path: \"/chat/completions\",\n models_path: Some(\"/models\"),\n default_model: \"\",\n suggested_models: &[],\n auth_kind: AIAuthKind::BearerApiKey,\n capabilities: PROMPT_ONLY,\n parameter_profile: DEFAULT_OPENAI_PARAMETERS,\n response_profile: OPENAI_RESPONSE_PROFILE,\n endpoint_variants: EMPTY_ENDPOINTS,\n api_key_env_hint: \"OPENAI_COMPATIBLE_API_KEY\",\n docs_url: None,\n },\n AIProviderDescriptor {\n id: AIProviderPresetId::Ollama,\n label: \"Ollama 本地模型\",\n provider_kind: AIProviderKind::Ollama,\n default_base_url: \"http://localhost:11434\",\n default_chat_path: \"/api/chat\",\n models_path: Some(\"/api/tags\"),\n default_model: \"qwen3:8b\",\n suggested_models: OLLAMA_MODELS,\n auth_kind: AIAuthKind::None,\n capabilities: AIProviderCapabilities {\n supports_model_discovery: true,\n supports_response_format_json_object: false,\n supports_json_schema: false,\n supports_thinking: true,\n supports_thinking_toggle: true,\n supports_reasoning_effort: false,\n supports_usage: true,\n supports_streaming: true,\n },\n parameter_profile: AIParameterProfile {\n temperature_min: 0.0,\n temperature_max: 2.0,\n default_temperature: 0.0,\n max_output_tokens: Some(32768),\n token_parameter: AITokenParameter::MaxTokens,\n thinking_strategy: AIThinkingStrategy::PromptOnly,\n },\n response_profile: OPENAI_RESPONSE_PROFILE,\n endpoint_variants: EMPTY_ENDPOINTS,\n api_key_env_hint: \"\",\n docs_url: Some(\"https://github.com/ollama/ollama/blob/main/docs/api.md\"),\n },\n];\n\npub fn provider_registry() -> &'static [AIProviderDescriptor] {\n PROVIDER_REGISTRY\n}\n\npub fn provider_descriptor(id: AIProviderPresetId) -> Option<&'static AIProviderDescriptor> {\n PROVIDER_REGISTRY\n .iter()\n .find(|descriptor| descriptor.id == id)\n}\n\n#[cfg(test)]\nmod tests {\n use super::*;\n\n #[test]\n fn registry_has_one_descriptor_for_each_supported_preset() {\n let expected = [\n AIProviderPresetId::DeepSeek,\n AIProviderPresetId::Kimi,\n AIProviderPresetId::QwenDashScope,\n AIProviderPresetId::ZhipuGlm,\n AIProviderPresetId::DoubaoArk,\n AIProviderPresetId::Minimax,\n AIProviderPresetId::Hunyuan,\n AIProviderPresetId::Siliconflow,\n AIProviderPresetId::BaiduQianfan,\n AIProviderPresetId::Baichuan,\n AIProviderPresetId::StepFun,\n AIProviderPresetId::Yi,\n AIProviderPresetId::CustomOpenAICompatible,\n AIProviderPresetId::Ollama,\n ];\n assert_eq!(provider_registry().len(), expected.len());\n for id in expected {\n assert_eq!(\n provider_registry()\n .iter()\n .filter(|item| item.id == id)\n .count(),\n 1\n );\n }\n }\n\n #[test]\n fn registry_snapshots_provider_specific_protocol_parameters() {\n let deepseek = provider_descriptor(AIProviderPresetId::DeepSeek).expect(\"DeepSeek\");\n assert_eq!(deepseek.default_base_url, \"https://api.deepseek.com\");\n assert!(deepseek.capabilities.supports_response_format_json_object);\n assert_eq!(\n deepseek.parameter_profile.thinking_strategy,\n AIThinkingStrategy::DeepSeekThinkingObject\n );\n\n let qwen = provider_descriptor(AIProviderPresetId::QwenDashScope).expect(\"Qwen\");\n assert!(qwen.capabilities.supports_response_format_json_object);\n assert_eq!(\n qwen.parameter_profile.thinking_strategy,\n AIThinkingStrategy::EnableThinkingBoolean\n );\n\n let minimax = provider_descriptor(AIProviderPresetId::Minimax).expect(\"MiniMax\");\n assert_eq!(\n minimax.parameter_profile.token_parameter,\n AITokenParameter::MaxCompletionTokens\n );\n assert_eq!(\n minimax.parameter_profile.thinking_strategy,\n AIThinkingStrategy::MiniMaxReasoningSplit\n );\n\n let qianfan = provider_descriptor(AIProviderPresetId::BaiduQianfan).expect(\"Qianfan\");\n assert_eq!(qianfan.auth_kind, AIAuthKind::BearerApiKey);\n assert_eq!(qianfan.default_base_url, \"https://qianfan.baidubce.com/v2\");\n }\n}\n"}, "f64cf0097f514b7395356b1f3df7c6af1020a12d": {"t": 1785757164.0500057, "n": 23417, "h": "1f108b13a19604d0", "c": "use serde::{Deserialize, Serialize};\n\nuse super::schema::{AIProviderKind, AIProviderPresetId};\n\n#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]\n#[serde(rename_all = \"snake_case\")]\npub enum AIAuthKind {\n None,\n BearerApiKey,\n ApiKeyHeader,\n QianfanAkSk,\n}\n\n#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]\n#[serde(rename_all = \"snake_case\")]\npub enum AITokenParameter {\n MaxTokens,\n MaxCompletionTokens,\n}\n\n#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]\n#[serde(rename_all = \"snake_case\")]\npub enum AIThinkingStrategy {\n None,\n DeepSeekThinkingObject,\n GenericThinkingObject,\n EnableThinkingBoolean,\n ReasoningEffort,\n MiniMaxReasoningSplit,\n PromptOnly,\n}\n\n#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]\n#[serde(rename_all = \"snake_case\")]\npub enum ResponseFieldPath {\n ChoicesMessageContent,\n ChoicesMessageOutputText,\n ChoicesMessageText,\n ChoicesMessageReasoningContent,\n ChoicesMessageReasoningDetails,\n RootOutputText,\n RootContent,\n}\n\n#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]\n#[serde(rename_all = \"camelCase\")]\npub struct AIProviderCapabilities {\n pub supports_model_discovery: bool,\n pub supports_response_format_json_object: bool,\n pub supports_json_schema: bool,\n pub supports_thinking: bool,\n pub supports_thinking_toggle: bool,\n pub supports_reasoning_effort: bool,\n pub supports_usage: bool,\n pub supports_streaming: bool,\n}\n\n#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]\n#[serde(rename_all = \"camelCase\")]\npub struct AIParameterProfile {\n pub temperature_min: f64,\n pub temperature_max: f64,\n pub default_temperature: f64,\n pub max_output_tokens: Option<u32>,\n pub token_parameter: AITokenParameter,\n pub thinking_strategy: AIThinkingStrategy,\n}\n\n#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]\n#[serde(rename_all = \"camelCase\")]\npub struct AIResponseProfile {\n pub content_paths: &'static [ResponseFieldPath],\n pub reasoning_paths: &'static [ResponseFieldPath],\n pub finish_reason_paths: &'static [ResponseFieldPath],\n pub usage_paths: &'static [ResponseFieldPath],\n pub strip_think_tags: bool,\n}\n\n#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]\n#[serde(rename_all = \"camelCase\")]\npub struct AIEndpointVariant {\n pub id: &'static str,\n pub label: &'static str,\n pub base_url: &'static str,\n}\n\n#[derive(Debug, Clone, Copy, PartialEq, Serialize)]\n#[serde(rename_all = \"camelCase\")]\npub struct AIProviderDescriptor {\n pub id: AIProviderPresetId,\n pub label: &'static str,\n pub provider_kind: AIProviderKind,\n pub default_base_url: &'static str,\n pub default_chat_path: &'static str,\n pub models_path: Option<&'static str>,\n pub default_model: &'static str,\n pub suggested_models: &'static [&'static str],\n pub auth_kind: AIAuthKind,\n pub capabilities: AIProviderCapabilities,\n pub parameter_profile: AIParameterProfile,\n pub response_profile: AIResponseProfile,\n pub endpoint_variants: &'static [AIEndpointVariant],\n pub api_key_env_hint: &'static str,\n pub docs_url: Option<&'static str>,\n}\n\nconst OPENAI_CONTENT_PATHS: &[ResponseFieldPath] = &[\n ResponseFieldPath::ChoicesMessageContent,\n ResponseFieldPath::ChoicesMessageOutputText,\n ResponseFieldPath::ChoicesMessageText,\n ResponseFieldPath::RootOutputText,\n ResponseFieldPath::RootContent,\n];\nconst OPENAI_REASONING_PATHS: &[ResponseFieldPath] = &[\n ResponseFieldPath::ChoicesMessageReasoningContent,\n ResponseFieldPath::ChoicesMessageReasoningDetails,\n];\nconst OPENAI_FINISH_PATHS: &[ResponseFieldPath] = &[];\nconst OPENAI_USAGE_PATHS: &[ResponseFieldPath] = &[];\nconst EMPTY_ENDPOINTS: &[AIEndpointVariant] = &[];\nconst DEEPSEEK_MODELS: &[&str] = &[\"deepseek-v4-flash\", \"deepseek-v4-pro\"];\nconst KIMI_MODELS: &[&str] = &[\"kimi-k2.6\", \"kimi-k2-thinking\", \"moonshot-v1-128k\"];\nconst QWEN_MODELS: &[&str] = &[\"qwen-plus\", \"qwen3.6-plus\", \"qwen3.5-plus\", \"qwen3-vl-plus\"];\nconst GLM_MODELS: &[&str] = &[\"glm-5.2\", \"glm-5\", \"glm-4.7\", \"glm-4.5\"];\nconst MINIMAX_MODELS: &[&str] = &[\"MiniMax-M2.5\", \"MiniMax-M2.1\", \"MiniMax-M2\"];\nconst DOUBAO_MODELS: &[&str] = &[\"ep-2024-...\", \"doubao-seed-1-6-250615\"];\nconst HUNYUAN_MODELS: &[&str] = &[\"hunyuan-turbos\", \"hunyuan-pro\"];\nconst STEPFUN_MODELS: &[&str] = &[\"step-3.5-flash\", \"step-3.5-turbo\"];\nconst OLLAMA_MODELS: &[&str] = &[\"qwen3:8b\", \"llama3.2:3b\"];\n\nconst DASHSCOPE_ENDPOINTS: &[AIEndpointVariant] = &[\n AIEndpointVariant {\n id: \"cn-beijing\",\n label: \"中国内地(北京)\",\n base_url: \"https://dashscope.aliyuncs.com/compatible-mode/v1\",\n },\n AIEndpointVariant {\n id: \"us-virginia\",\n label: \"美国(弗吉尼亚)\",\n base_url: \"https://dashscope-us.aliyuncs.com/compatible-mode/v1\",\n },\n AIEndpointVariant {\n id: \"singapore-workspace\",\n label: \"新加坡(Workspace Endpoint)\",\n base_url: \"https://{WorkspaceId}.ap-southeast-1.maas.aliyuncs.com/compatible-mode/v1\",\n },\n AIEndpointVariant {\n id: \"japan-workspace\",\n label: \"日本(Workspace Endpoint)\",\n base_url: \"https://{WorkspaceId}.ap-northeast-1.maas.aliyuncs.com/compatible-mode/v1\",\n },\n];\n\nconst OPENAI_RESPONSE_PROFILE: AIResponseProfile = AIResponseProfile {\n content_paths: OPENAI_CONTENT_PATHS,\n reasoning_paths: OPENAI_REASONING_PATHS,\n finish_reason_paths: OPENAI_FINISH_PATHS,\n usage_paths: OPENAI_USAGE_PATHS,\n strip_think_tags: false,\n};\nconst MINIMAX_RESPONSE_PROFILE: AIResponseProfile = AIResponseProfile {\n content_paths: OPENAI_CONTENT_PATHS,\n reasoning_paths: OPENAI_REASONING_PATHS,\n finish_reason_paths: OPENAI_FINISH_PATHS,\n usage_paths: OPENAI_USAGE_PATHS,\n strip_think_tags: true,\n};\n\nconst PROMPT_ONLY: AIProviderCapabilities = AIProviderCapabilities {\n supports_model_discovery: true,\n supports_response_format_json_object: false,\n supports_json_schema: false,\n supports_thinking: false,\n supports_thinking_toggle: false,\n supports_reasoning_effort: false,\n supports_usage: true,\n supports_streaming: true,\n};\n\nconst DEFAULT_OPENAI_PARAMETERS: AIParameterProfile = AIParameterProfile {\n temperature_min: 0.0,\n temperature_max: 2.0,\n default_temperature: 0.0,\n max_output_tokens: None,\n token_parameter: AITokenParameter::MaxTokens,\n thinking_strategy: AIThinkingStrategy::PromptOnly,\n};\nconst QWEN_CAPABILITIES: AIProviderCapabilities = AIProviderCapabilities {\n supports_model_discovery: true,\n supports_response_format_json_object: true,\n supports_json_schema: false,\n supports_thinking: true,\n supports_thinking_toggle: true,\n supports_reasoning_effort: false,\n supports_usage: true,\n supports_streaming: true,\n};\n\npub static PROVIDER_REGISTRY: &[AIProviderDescriptor] = &[\n AIProviderDescriptor {\n id: AIProviderPresetId::DeepSeek,\n label: \"DeepSeek\",\n provider_kind: AIProviderKind::OpenAICompatible,\n default_base_url: \"https://api.deepseek.com\",\n default_chat_path: \"/chat/completions\",\n models_path: Some(\"/models\"),\n default_model: \"deepseek-v4-flash\",\n suggested_models: DEEPSEEK_MODELS,\n auth_kind: AIAuthKind::BearerApiKey,\n capabilities: AIProviderCapabilities {\n supports_model_discovery: true,\n supports_response_format_json_object: true,\n supports_json_schema: false,\n supports_thinking: true,\n supports_thinking_toggle: true,\n supports_reasoning_effort: true,\n supports_usage: true,\n supports_streaming: true,\n },\n parameter_profile: AIParameterProfile {\n temperature_min: 0.0,\n temperature_max: 2.0,\n default_temperature: 0.0,\n max_output_tokens: Some(8192),\n token_parameter: AITokenParameter::MaxTokens,\n thinking_strategy: AIThinkingStrategy::DeepSeekThinkingObject,\n },\n response_profile: OPENAI_RESPONSE_PROFILE,\n endpoint_variants: EMPTY_ENDPOINTS,\n api_key_env_hint: \"DEEPSEEK_API_KEY\",\n docs_url: Some(\"https://api-docs.deepseek.com/\"),\n },\n AIProviderDescriptor {\n id: AIProviderPresetId::Kimi,\n label: \"Kimi / Moonshot\",\n provider_kind: AIProviderKind::OpenAICompatible,\n default_base_url: \"https://api.moonshot.ai/v1\",\n default_chat_path: \"/chat/completions\",\n models_path: Some(\"/models\"),\n default_model: \"kimi-k2.6\",\n suggested_models: KIMI_MODELS,\n auth_kind: AIAuthKind::BearerApiKey,\n capabilities: AIProviderCapabilities {\n supports_model_discovery: true,\n supports_response_format_json_object: true,\n supports_json_schema: false,\n supports_thinking: true,\n supports_thinking_toggle: true,\n supports_reasoning_effort: false,\n supports_usage: true,\n supports_streaming: true,\n },\n parameter_profile: AIParameterProfile {\n temperature_min: 0.0,\n temperature_max: 1.0,\n default_temperature: 0.0,\n max_output_tokens: Some(32768),\n token_parameter: AITokenParameter::MaxTokens,\n thinking_strategy: AIThinkingStrategy::GenericThinkingObject,\n },\n response_profile: OPENAI_RESPONSE_PROFILE,\n endpoint_variants: EMPTY_ENDPOINTS,\n api_key_env_hint: \"MOONSHOT_API_KEY\",\n docs_url: Some(\"https://platform.kimi.ai/docs\"),\n },\n AIProviderDescriptor {\n id: AIProviderPresetId::QwenDashScope,\n label: \"Qwen / 阿里云百炼\",\n provider_kind: AIProviderKind::OpenAICompatible,\n default_base_url: \"https://dashscope.aliyuncs.com/compatible-mode/v1\",\n default_chat_path: \"/chat/completions\",\n models_path: Some(\"/models\"),\n default_model: \"qwen-plus\",\n suggested_models: QWEN_MODELS,\n auth_kind: AIAuthKind::BearerApiKey,\n capabilities: QWEN_CAPABILITIES,\n parameter_profile: AIParameterProfile {\n temperature_min: 0.0,\n temperature_max: 2.0,\n default_temperature: 0.0,\n max_output_tokens: Some(32768),\n token_parameter: AITokenParameter::MaxTokens,\n thinking_strategy: AIThinkingStrategy::EnableThinkingBoolean,\n },\n response_profile: OPENAI_RESPONSE_PROFILE,\n endpoint_variants: DASHSCOPE_ENDPOINTS,\n api_key_env_hint: \"DASHSCOPE_API_KEY\",\n docs_url: Some(\"https://help.aliyun.com/model-studio/\"),\n },\n AIProviderDescriptor {\n id: AIProviderPresetId::ZhipuGlm,\n label: \"智谱 GLM\",\n provider_kind: AIProviderKind::OpenAICompatible,\n default_base_url: \"https://open.bigmodel.cn/api/paas/v4\",\n default_chat_path: \"/chat/completions\",\n models_path: None,\n default_model: \"glm-5.2\",\n suggested_models: GLM_MODELS,\n auth_kind: AIAuthKind::BearerApiKey,\n capabilities: AIProviderCapabilities {\n supports_model_discovery: false,\n supports_response_format_json_object: true,\n supports_json_schema: false,\n supports_thinking: true,\n supports_thinking_toggle: true,\n supports_reasoning_effort: true,\n supports_usage: true,\n supports_streaming: true,\n },\n parameter_profile: AIParameterProfile {\n temperature_min: 0.0,\n temperature_max: 2.0,\n default_temperature: 0.0,\n max_output_tokens: Some(32768),\n token_parameter: AITokenParameter::MaxTokens,\n thinking_strategy: AIThinkingStrategy::GenericThinkingObject,\n },\n response_profile: OPENAI_RESPONSE_PROFILE,\n endpoint_variants: EMPTY_ENDPOINTS,\n api_key_env_hint: \"ZHIPUAI_API_KEY\",\n docs_url: Some(\"https://docs.bigmodel.cn/\"),\n },\n AIProviderDescriptor {\n id: AIProviderPresetId::DoubaoArk,\n label: \"豆包 / 火山方舟\",\n provider_kind: AIProviderKind::OpenAICompatible,\n default_base_url: \"https://ark.cn-beijing.volces.com/api/v3\",\n default_chat_path: \"/chat/completions\",\n models_path: Some(\"/models\"),\n default_model: \"\",\n suggested_models: DOUBAO_MODELS,\n auth_kind: AIAuthKind::BearerApiKey,\n capabilities: PROMPT_ONLY,\n parameter_profile: AIParameterProfile {\n temperature_min: 0.0,\n temperature_max: 2.0,\n default_temperature: 0.0,\n max_output_tokens: Some(32768),\n token_parameter: AITokenParameter::MaxTokens,\n thinking_strategy: AIThinkingStrategy::PromptOnly,\n },\n response_profile: OPENAI_RESPONSE_PROFILE,\n endpoint_variants: EMPTY_ENDPOINTS,\n api_key_env_hint: \"ARK_API_KEY\",\n docs_url: Some(\"https://www.volcengine.com/docs/82379\"),\n },\n AIProviderDescriptor {\n id: AIProviderPresetId::Minimax,\n label: \"MiniMax\",\n provider_kind: AIProviderKind::OpenAICompatible,\n default_base_url: \"https://api.minimaxi.com/v1\",\n default_chat_path: \"/chat/completions\",\n models_path: Some(\"/models\"),\n default_model: \"MiniMax-M2.5\",\n suggested_models: MINIMAX_MODELS,\n auth_kind: AIAuthKind::BearerApiKey,\n capabilities: AIProviderCapabilities {\n supports_model_discovery: true,\n supports_response_format_json_object: true,\n supports_json_schema: false,\n supports_thinking: true,\n supports_thinking_toggle: true,\n supports_reasoning_effort: false,\n supports_usage: true,\n supports_streaming: true,\n },\n parameter_profile: AIParameterProfile {\n temperature_min: 0.0,\n temperature_max: 1.0,\n default_temperature: 0.0,\n max_output_tokens: Some(32768),\n token_parameter: AITokenParameter::MaxCompletionTokens,\n thinking_strategy: AIThinkingStrategy::MiniMaxReasoningSplit,\n },\n response_profile: MINIMAX_RESPONSE_PROFILE,\n endpoint_variants: EMPTY_ENDPOINTS,\n api_key_env_hint: \"MINIMAX_API_KEY\",\n docs_url: Some(\"https://platform.minimaxi.com/document\"),\n },\n AIProviderDescriptor {\n id: AIProviderPresetId::Hunyuan,\n label: \"腾讯混元\",\n provider_kind: AIProviderKind::OpenAICompatible,\n default_base_url: \"https://api.hunyuan.cloud.tencent.com/v1\",\n default_chat_path: \"/chat/completions\",\n models_path: Some(\"/models\"),\n default_model: \"hunyuan-turbos\",\n suggested_models: HUNYUAN_MODELS,\n auth_kind: AIAuthKind::BearerApiKey,\n capabilities: PROMPT_ONLY,\n parameter_profile: DEFAULT_OPENAI_PARAMETERS,\n response_profile: OPENAI_RESPONSE_PROFILE,\n endpoint_variants: EMPTY_ENDPOINTS,\n api_key_env_hint: \"HUNYUAN_API_KEY\",\n docs_url: Some(\"https://cloud.tencent.com/document/product/1729\"),\n },\n AIProviderDescriptor {\n id: AIProviderPresetId::Siliconflow,\n label: \"SiliconFlow / 硅基流动\",\n provider_kind: AIProviderKind::OpenAICompatible,\n default_base_url: \"https://api.siliconflow.cn/v1\",\n default_chat_path: \"/chat/completions\",\n models_path: Some(\"/models\"),\n default_model: \"\",\n suggested_models: &[],\n auth_kind: AIAuthKind::BearerApiKey,\n capabilities: PROMPT_ONLY,\n parameter_profile: DEFAULT_OPENAI_PARAMETERS,\n response_profile: OPENAI_RESPONSE_PROFILE,\n endpoint_variants: EMPTY_ENDPOINTS,\n api_key_env_hint: \"SILICONFLOW_API_KEY\",\n docs_url: Some(\"https://docs.siliconflow.cn/\"),\n },\n AIProviderDescriptor {\n id: AIProviderPresetId::BaiduQianfan,\n label: \"百度千帆 / 文心\",\n provider_kind: AIProviderKind::OpenAICompatible,\n default_base_url: \"https://qianfan.baidubce.com/v2\",\n default_chat_path: \"/chat/completions\",\n models_path: None,\n default_model: \"ernie-4.5-turbo-32k\",\n suggested_models: &[\"ernie-4.5-turbo-32k\", \"ernie-speed-128k\"],\n auth_kind: AIAuthKind::BearerApiKey,\n capabilities: PROMPT_ONLY,\n parameter_profile: DEFAULT_OPENAI_PARAMETERS,\n response_profile: OPENAI_RESPONSE_PROFILE,\n endpoint_variants: EMPTY_ENDPOINTS,\n api_key_env_hint: \"QIANFAN_API_KEY\",\n docs_url: Some(\"https://cloud.baidu.com/doc/WENXINWORKSHOP/\"),\n },\n AIProviderDescriptor {\n id: AIProviderPresetId::Baichuan,\n label: \"百川智能(兼容入口)\",\n provider_kind: AIProviderKind::OpenAICompatible,\n default_base_url: \"\",\n default_chat_path: \"/chat/completions\",\n models_path: None,\n default_model: \"\",\n suggested_models: &[\"Baichuan4-Turbo\"],\n auth_kind: AIAuthKind::BearerApiKey,\n capabilities: PROMPT_ONLY,\n parameter_profile: DEFAULT_OPENAI_PARAMETERS,\n response_profile: OPENAI_RESPONSE_PROFILE,\n endpoint_variants: EMPTY_ENDPOINTS,\n api_key_env_hint: \"BAICHUAN_API_KEY\",\n docs_url: Some(\"https://platform.baichuan-ai.com/docs/api\"),\n },\n AIProviderDescriptor {\n id: AIProviderPresetId::StepFun,\n label: \"阶跃星辰 / StepFun\",\n provider_kind: AIProviderKind::OpenAICompatible,\n default_base_url: \"https://api.stepfun.com/v1\",\n default_chat_path: \"/chat/completions\",\n models_path: Some(\"/models\"),\n default_model: \"step-3.5-flash\",\n suggested_models: STEPFUN_MODELS,\n auth_kind: AIAuthKind::BearerApiKey,\n capabilities: PROMPT_ONLY,\n parameter_profile: DEFAULT_OPENAI_PARAMETERS,\n response_profile: OPENAI_RESPONSE_PROFILE,\n endpoint_variants: EMPTY_ENDPOINTS,\n api_key_env_hint: \"STEPFUN_API_KEY\",\n docs_url: Some(\"https://platform.stepfun.com/docs\"),\n },\n AIProviderDescriptor {\n id: AIProviderPresetId::Yi,\n label: \"零一万物 / Yi(兼容平台)\",\n provider_kind: AIProviderKind::OpenAICompatible,\n default_base_url: \"\",\n default_chat_path: \"/chat/completions\",\n models_path: None,\n default_model: \"\",\n suggested_models: &[\"yi-large\"],\n auth_kind: AIAuthKind::BearerApiKey,\n capabilities: PROMPT_ONLY,\n parameter_profile: DEFAULT_OPENAI_PARAMETERS,\n response_profile: OPENAI_RESPONSE_PROFILE,\n endpoint_variants: EMPTY_ENDPOINTS,\n api_key_env_hint: \"YI_API_KEY\",\n docs_url: Some(\"https://platform.lingyiwanwu.com/\"),\n },\n AIProviderDescriptor {\n id: AIProviderPresetId::CustomOpenAICompatible,\n label: \"自定义 OpenAI-compatible\",\n provider_kind: AIProviderKind::OpenAICompatible,\n default_base_url: \"\",\n default_chat_path: \"/chat/completions\",\n models_path: Some(\"/models\"),\n default_model: \"\",\n suggested_models: &[],\n auth_kind: AIAuthKind::BearerApiKey,\n capabilities: PROMPT_ONLY,\n parameter_profile: DEFAULT_OPENAI_PARAMETERS,\n response_profile: OPENAI_RESPONSE_PROFILE,\n endpoint_variants: EMPTY_ENDPOINTS,\n api_key_env_hint: \"OPENAI_COMPATIBLE_API_KEY\",\n docs_url: None,\n },\n AIProviderDescriptor {\n id: AIProviderPresetId::Ollama,\n label: \"Ollama 本地模型\",\n provider_kind: AIProviderKind::Ollama,\n default_base_url: \"http://localhost:11434\",\n default_chat_path: \"/api/chat\",\n models_path: Some(\"/api/tags\"),\n default_model: \"qwen3:8b\",\n suggested_models: OLLAMA_MODELS,\n auth_kind: AIAuthKind::None,\n capabilities: AIProviderCapabilities {\n supports_model_discovery: true,\n supports_response_format_json_object: false,\n supports_json_schema: false,\n supports_thinking: true,\n supports_thinking_toggle: true,\n supports_reasoning_effort: false,\n supports_usage: true,\n supports_streaming: true,\n },\n parameter_profile: AIParameterProfile {\n temperature_min: 0.0,\n temperature_max: 2.0,\n default_temperature: 0.0,\n max_output_tokens: Some(32768),\n token_parameter: AITokenParameter::MaxTokens,\n thinking_strategy: AIThinkingStrategy::PromptOnly,\n },\n response_profile: OPENAI_RESPONSE_PROFILE,\n endpoint_variants: EMPTY_ENDPOINTS,\n api_key_env_hint: \"\",\n docs_url: Some(\"https://github.com/ollama/ollama/blob/main/docs/api.md\"),\n },\n];\n\npub fn provider_registry() -> &'static [AIProviderDescriptor] {\n PROVIDER_REGISTRY\n}\n\npub fn provider_descriptor(id: AIProviderPresetId) -> Option<&'static AIProviderDescriptor> {\n PROVIDER_REGISTRY\n .iter()\n .find(|descriptor| descriptor.id == id)\n}\n\n#[cfg(test)]\nmod tests {\n use super::*;\n\n #[test]\n fn registry_has_one_descriptor_for_each_supported_preset() {\n let expected = [\n AIProviderPresetId::DeepSeek,\n AIProviderPresetId::Kimi,\n AIProviderPresetId::QwenDashScope,\n AIProviderPresetId::ZhipuGlm,\n AIProviderPresetId::DoubaoArk,\n AIProviderPresetId::Minimax,\n AIProviderPresetId::Hunyuan,\n AIProviderPresetId::Siliconflow,\n AIProviderPresetId::BaiduQianfan,\n AIProviderPresetId::Baichuan,\n AIProviderPresetId::StepFun,\n AIProviderPresetId::Yi,\n AIProviderPresetId::CustomOpenAICompatible,\n AIProviderPresetId::Ollama,\n ];\n assert_eq!(provider_registry().len(), expected.len());\n for id in expected {\n assert_eq!(\n provider_registry()\n .iter()\n .filter(|item| item.id == id)\n .count(),\n 1\n );\n }\n }\n\n #[test]\n fn registry_snapshots_provider_specific_protocol_parameters() {\n let deepseek = provider_descriptor(AIProviderPresetId::DeepSeek).expect(\"DeepSeek\");\n assert_eq!(deepseek.default_base_url, \"https://api.deepseek.com\");\n assert!(deepseek.capabilities.supports_response_format_json_object);\n assert_eq!(\n deepseek.parameter_profile.thinking_strategy,\n AIThinkingStrategy::DeepSeekThinkingObject\n );\n\n let qwen = provider_descriptor(AIProviderPresetId::QwenDashScope).expect(\"Qwen\");\n assert!(qwen.capabilities.supports_response_format_json_object);\n assert_eq!(\n qwen.parameter_profile.thinking_strategy,\n AIThinkingStrategy::EnableThinkingBoolean\n );\n\n let minimax = provider_descriptor(AIProviderPresetId::Minimax).expect(\"MiniMax\");\n assert_eq!(\n minimax.parameter_profile.token_parameter,\n AITokenParameter::MaxCompletionTokens\n );\n assert_eq!(\n minimax.parameter_profile.thinking_strategy,\n AIThinkingStrategy::MiniMaxReasoningSplit\n );\n\n let qianfan = provider_descriptor(AIProviderPresetId::BaiduQianfan).expect(\"Qianfan\");\n assert_eq!(qianfan.auth_kind, AIAuthKind::BearerApiKey);\n assert_eq!(qianfan.default_base_url, \"https://qianfan.baidubce.com/v2\");\n }\n}\n"}, "1f0bac73e9bf477486d670d60c83ba1350f18abc": {"t": 1786067483.147143, "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"}, "1f0bac73e9bf477486d670d60c83ba1350f18abc": {"t": 1786067806.335492, "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"}}, "src-tauri/src/ai/settings.rs": {"1f0bac73e9bf477486d670d60c83ba1350f18abc": {"t": 1786067649.2882922, "n": 46013, "h": "3716aa3722853b01", "c": "use std::{collections::HashMap, sync::Mutex, time::Instant};\n\nuse rusqlite::{params, OptionalExtension};\nuse serde::{Deserialize, Serialize};\nuse tauri::{Runtime, State, WebviewWindow};\n\nuse super::{\n ollama::OllamaProvider,\n openai_compatible::OpenAICompatibleProvider,\n presets::{all_provider_presets, provider_preset, AIProviderPreset},\n provider::AIProvider,\n schema::{\n AIConnectionTestResult, AICustomProviderProfile, AIModelInfo, AIProviderKind,\n AIProviderPresetId,\n },\n trace::AITraceMode,\n};\nuse crate::{\n db::{Database, DbError},\n window_auth::require_main_window,\n};\n\npub const AI_SETTINGS_KEY: &str = \"ai_settings_v1\";\nconst AI_CREDENTIAL_SERVICE: &str = \"com.startlan.zencanvas\";\nconst AI_CREDENTIAL_USER: &str = \"ai-api-key\";\nstatic AI_SETTINGS_SAVE_LOCK: Mutex<()> = Mutex::new(());\n\n#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq)]\n#[serde(rename_all = \"snake_case\")]\npub enum ApiKeyAction {\n #[default]\n Preserve,\n Replace,\n Clear,\n}\n\n#[derive(Debug, Clone, Serialize, Deserialize)]\n#[serde(default, rename_all = \"camelCase\")]\npub struct AISettings {\n pub enabled: bool,\n pub provider: AIProviderKind,\n pub preset: AIProviderPresetId,\n pub base_url: String,\n pub chat_path: String,\n #[serde(default)]\n pub models_path: Option<String>,\n pub api_key: String,\n #[serde(default, skip_serializing)]\n pub api_key_action: ApiKeyAction,\n #[serde(default)]\n pub api_key_configured: bool,\n pub model: String,\n pub temperature: f32,\n pub max_tokens: u32,\n pub batch_size: usize,\n pub classification_concurrency: usize,\n pub timeout_seconds: u64,\n pub send_full_path: bool,\n pub send_parent_path: bool,\n pub classification_mode: String,\n pub cleanup_ai_enabled: bool,\n pub force_json_output: bool,\n pub enable_thinking: bool,\n pub reasoning_effort: Option<String>,\n pub extra_body_json: Option<String>,\n #[serde(default)]\n pub diagnostics_mode: AITraceMode,\n #[serde(default)]\n pub custom_profiles: Vec<AICustomProviderProfile>,\n #[serde(default)]\n pub active_custom_profile_id: Option<String>,\n}\n\nimpl Default for AISettings {\n fn default() -> Self {\n Self {\n enabled: false,\n provider: AIProviderKind::OpenAICompatible,\n preset: AIProviderPresetId::DeepSeek,\n base_url: \"https://api.deepseek.com\".to_string(),\n chat_path: \"/chat/completions\".to_string(),\n models_path: Some(\"/models\".to_string()),\n api_key: String::new(),\n api_key_action: ApiKeyAction::Preserve,\n api_key_configured: false,\n model: \"deepseek-v4-flash\".to_string(),\n temperature: 0.0,\n max_tokens: 8192,\n batch_size: 10,\n classification_concurrency: 2,\n timeout_seconds: 120,\n send_full_path: false,\n send_parent_path: true,\n classification_mode: \"ai_first\".to_string(),\n cleanup_ai_enabled: true,\n force_json_output: true,\n enable_thinking: false,\n reasoning_effort: None,\n extra_body_json: None,\n diagnostics_mode: AITraceMode::Off,\n custom_profiles: Vec::new(),\n active_custom_profile_id: None,\n }\n }\n}\n\npub fn get_ai_settings_for_db(db: &Database) -> Result<AISettings, DbError> {\n get_ai_settings_with_store(db, &SystemCredentialStore)\n}\n\npub fn get_ai_settings_with_store(\n db: &Database,\n credentials: &impl CredentialStore,\n) -> Result<AISettings, DbError> {\n let conn = db.conn()?;\n let settings_json = conn\n .query_row(\n \"SELECT value FROM app_settings WHERE key = ?1\",\n params![AI_SETTINGS_KEY],\n |row| row.get::<_, String>(0),\n )\n .optional()?;\n\n let mut settings = match settings_json {\n Some(value) => serde_json::from_str(&value)\n .map(normalize_ai_settings)\n .map_err(DbError::from)?,\n None => AISettings::default(),\n };\n if !settings.api_key.is_empty() {\n credentials\n .set(&settings.api_key)\n .map_err(DbError::Validation)?;\n settings.api_key.clear();\n persist_ai_settings_without_secret(db, &settings)?;\n }\n settings.api_key = credentials\n .get()\n .map_err(DbError::Validation)?\n .unwrap_or_default();\n for profile in &mut settings.custom_profiles {\n profile.api_key_configured = credentials\n .get_profile(&profile.id)\n .map_err(DbError::Validation)?\n .is_some();\n }\n if let Some(profile_id) = active_profile_id(&settings) {\n settings.api_key = credentials\n .get_profile(profile_id)\n .map_err(DbError::Validation)?\n .unwrap_or_default();\n }\n settings.api_key_action = ApiKeyAction::Preserve;\n settings.api_key_configured = !settings.api_key.is_empty();\n Ok(settings)\n}\n\npub fn save_ai_settings_for_db(\n db: &Database,\n settings: &AISettings,\n) -> Result<AISettings, DbError> {\n save_ai_settings_with_store(db, settings, &SystemCredentialStore)\n}\n\npub fn save_ai_settings_with_store(\n db: &Database,\n settings: &AISettings,\n credentials: &impl CredentialStore,\n) -> Result<AISettings, DbError> {\n let _transaction_guard = AI_SETTINGS_SAVE_LOCK\n .lock()\n .map_err(|_| DbError::Validation(\"credential_transaction_lock_poisoned\".to_string()))?;\n let previous_profile_ids = stored_custom_profile_ids(db)?;\n let mut normalized = normalize_ai_settings(settings.clone());\n validate_ai_settings(&normalized, !cfg!(debug_assertions)).map_err(DbError::Validation)?;\n let profile_id = active_profile_id(&normalized).map(ToString::to_string);\n let previous_key = credential_get(credentials, profile_id.as_deref())?;\n let mut credential_changed = false;\n match normalized.api_key_action {\n ApiKeyAction::Preserve => {\n normalized.api_key = previous_key.clone().unwrap_or_default();\n }\n ApiKeyAction::Replace => {\n if normalized.api_key.is_empty() {\n return Err(DbError::Validation(\n \"Replacing the AI API key requires a non-empty value.\".to_string(),\n ));\n }\n credential_set(credentials, profile_id.as_deref(), &normalized.api_key)?;\n credential_changed = true;\n verify_credential_change(\n credentials,\n profile_id.as_deref(),\n Some(normalized.api_key.as_str()),\n previous_key.as_deref(),\n )?;\n }\n ApiKeyAction::Clear => {\n credential_delete(credentials, profile_id.as_deref())?;\n credential_changed = true;\n verify_credential_change(\n credentials,\n profile_id.as_deref(),\n None,\n previous_key.as_deref(),\n )?;\n normalized.api_key.clear();\n }\n }\n if normalized.enabled\n && normalized.provider != AIProviderKind::Ollama\n && normalized.api_key.is_empty()\n {\n if credential_changed {\n rollback_credential_change(credentials, profile_id.as_deref(), previous_key.as_deref())\n .map_err(DbError::Validation)?;\n }\n return Err(DbError::Validation(\n \"Enabled cloud AI providers require a configured API key.\".to_string(),\n ));\n }\n normalized.api_key_action = ApiKeyAction::Preserve;\n normalized.api_key_configured = !normalized.api_key.is_empty();\n let active_profile_id = active_profile_id(&normalized).map(ToString::to_string);\n for profile in &mut normalized.custom_profiles {\n profile.api_key_configured = active_profile_id\n .as_deref()\n .map(|active| active == profile.id)\n .unwrap_or(false)\n && normalized.api_key_configured;\n }\n if let Err(error) = persist_ai_settings_without_secret(db, &normalized) {\n if credential_changed {\n rollback_credential_change(credentials, profile_id.as_deref(), previous_key.as_deref())\n .map_err(|rollback| {\n DbError::Validation(format!(\n \"AI settings save failed: {error}; credential rollback failed: {rollback}\"\n ))\n })?;\n }\n return Err(error);\n }\n let next_profile_ids = normalized\n .custom_profiles\n .iter()\n .map(|profile| profile.id.as_str())\n .collect::<std::collections::HashSet<_>>();\n for profile_id in previous_profile_ids {\n if !next_profile_ids.contains(profile_id.as_str()) {\n credentials\n .delete_profile(&profile_id)\n .map_err(DbError::Validation)?;\n }\n }\n Ok(normalized)\n}\n\nfn verify_credential_change(\n credentials: &impl CredentialStore,\n profile_id: Option<&str>,\n expected: Option<&str>,\n previous: Option<&str>,\n) -> Result<(), DbError> {\n match credential_get(credentials, profile_id) {\n Ok(actual) if actual.as_deref() == expected => Ok(()),\n Ok(_) => {\n rollback_credential_change(credentials, profile_id, previous).map_err(|rollback| {\n DbError::Validation(format!(\n \"credential read-back verification failed; credential rollback failed: {rollback}\"\n ))\n })?;\n Err(DbError::Validation(\n \"credential read-back verification failed\".to_string(),\n ))\n }\n Err(read_error) => {\n rollback_credential_change(credentials, profile_id, previous).map_err(|rollback| {\n DbError::Validation(format!(\n \"credential read-back failed: {read_error}; credential rollback failed: {rollback}\"\n ))\n })?;\n Err(DbError::Validation(format!(\n \"credential read-back failed: {read_error}\"\n )))\n }\n }\n}\n\nfn rollback_credential_change(\n credentials: &impl CredentialStore,\n profile_id: Option<&str>,\n previous: Option<&str>,\n) -> Result<(), String> {\n match previous {\n Some(value) => {\n credential_set(credentials, profile_id, value).map_err(|error| error.to_string())\n }\n None => credential_delete(credentials, profile_id).map_err(|error| error.to_string()),\n }\n}\n\nfn credential_get(\n credentials: &impl CredentialStore,\n profile_id: Option<&str>,\n) -> Result<Option<String>, DbError> {\n let value = match profile_id {\n Some(profile_id) => credentials.get_profile(profile_id),\n None => credentials.get(),\n };\n value.map_err(DbError::Validation)\n}\n\nfn credential_set(\n credentials: &impl CredentialStore,\n profile_id: Option<&str>,\n value: &str,\n) -> Result<(), DbError> {\n let result = match profile_id {\n Some(profile_id) => credentials.set_profile(profile_id, value),\n None => credentials.set(value),\n };\n result.map_err(DbError::Validation)\n}\n\nfn credential_delete(\n credentials: &impl CredentialStore,\n profile_id: Option<&str>,\n) -> Result<(), DbError> {\n let result = match profile_id {\n Some(profile_id) => credentials.delete_profile(profile_id),\n None => credentials.delete(),\n };\n result.map_err(DbError::Validation)\n}\n\nfn active_profile_id(settings: &AISettings) -> Option<&str> {\n if settings.preset != AIProviderPresetId::CustomOpenAICompatible {\n return None;\n }\n settings\n .active_custom_profile_id\n .as_deref()\n .filter(|profile_id| {\n settings\n .custom_profiles\n .iter()\n .any(|profile| profile.id == *profile_id)\n })\n}\n\nfn is_valid_profile_id(value: &str) -> bool {\n !value.is_empty()\n && value.len() <= 80\n && value\n .chars()\n .all(|character| character.is_ascii_alphanumeric() || matches!(character, '-' | '_'))\n}\n\nfn persist_ai_settings_without_secret(db: &Database, settings: &AISettings) -> Result<(), DbError> {\n let mut persisted = settings.clone();\n persisted.api_key.clear();\n persisted.api_key_configured = false;\n for profile in &mut persisted.custom_profiles {\n profile.api_key_configured = false;\n }\n let conn = db.conn()?;\n let settings_json = serde_json::to_string(&persisted)?;\n conn.execute(\n r#\"\n INSERT INTO app_settings (key, value)\n VALUES (?1, ?2)\n ON CONFLICT(key) DO UPDATE SET value = excluded.value\n \"#,\n params![AI_SETTINGS_KEY, settings_json],\n )?;\n Ok(())\n}\n\nfn stored_custom_profile_ids(db: &Database) -> Result<std::collections::HashSet<String>, DbError> {\n let conn = db.conn()?;\n let value = conn\n .query_row(\n \"SELECT value FROM app_settings WHERE key = ?1\",\n params![AI_SETTINGS_KEY],\n |row| row.get::<_, String>(0),\n )\n .optional()?;\n let Some(value) = value else {\n return Ok(std::collections::HashSet::new());\n };\n let settings = serde_json::from_str::<AISettings>(&value)?;\n Ok(settings\n .custom_profiles\n .into_iter()\n .map(|profile| profile.id)\n .collect())\n}\n\npub trait CredentialStore {\n fn set(&self, value: &str) -> Result<(), String>;\n fn get(&self) -> Result<Option<String>, String>;\n fn delete(&self) -> Result<(), String>;\n\n fn set_profile(&self, _profile_id: &str, value: &str) -> Result<(), String> {\n self.set(value)\n }\n\n fn get_profile(&self, _profile_id: &str) -> Result<Option<String>, String> {\n self.get()\n }\n\n fn delete_profile(&self, _profile_id: &str) -> Result<(), String> {\n self.delete()\n }\n}\n\npub struct SystemCredentialStore;\n\n#[derive(Default)]\npub struct InMemoryCredentialStore {\n main: Mutex<Option<String>>,\n profiles: Mutex<HashMap<String, String>>,\n}\n\nimpl CredentialStore for InMemoryCredentialStore {\n fn set(&self, value: &str) -> Result<(), String> {\n *self\n .main\n .lock()\n .map_err(|_| \"in-memory credential store is unavailable\".to_string())? =\n Some(value.trim().to_string());\n Ok(())\n }\n\n fn get(&self) -> Result<Option<String>, String> {\n self.main\n .lock()\n .map(|value| value.clone())\n .map_err(|_| \"in-memory credential store is unavailable\".to_string())\n }\n\n fn delete(&self) -> Result<(), String> {\n *self\n .main\n .lock()\n .map_err(|_| \"in-memory credential store is unavailable\".to_string())? = None;\n Ok(())\n }\n\n fn set_profile(&self, profile_id: &str, value: &str) -> Result<(), String> {\n self.profiles\n .lock()\n .map_err(|_| \"in-memory credential store is unavailable\".to_string())?\n .insert(profile_id.to_string(), value.trim().to_string());\n Ok(())\n }\n\n fn get_profile(&self, profile_id: &str) -> Result<Option<String>, String> {\n self.profiles\n .lock()\n .map(|values| values.get(profile_id).cloned())\n .map_err(|_| \"in-memory credential store is unavailable\".to_string())\n }\n\n fn delete_profile(&self, profile_id: &str) -> Result<(), String> {\n self.profiles\n .lock()\n .map_err(|_| \"in-memory credential store is unavailable\".to_string())?\n .remove(profile_id);\n Ok(())\n }\n}\n\nfn credential_entry() -> Result<keyring::Entry, String> {\n keyring::Entry::new(AI_CREDENTIAL_SERVICE, AI_CREDENTIAL_USER)\n .map_err(|error| format!(\"failed to open system credential store: {error}\"))\n}\n\nfn profile_credential_entry(profile_id: &str) -> Result<keyring::Entry, String> {\n if !is_valid_profile_id(profile_id) {\n return Err(\"AI custom provider profile ID is invalid.\".to_string());\n }\n keyring::Entry::new(\n \"com.startlan.zencanvas.ai-profile\",\n &format!(\"ai-api-key-{profile_id}\"),\n )\n .map_err(|error| format!(\"failed to open custom provider credential store: {error}\"))\n}\n\nimpl CredentialStore for SystemCredentialStore {\n fn set(&self, value: &str) -> Result<(), String> {\n credential_entry()?\n .set_password(value.trim())\n .map_err(|error| format!(\"failed to save API key in system credential store: {error}\"))\n }\n fn get(&self) -> Result<Option<String>, String> {\n match credential_entry()?.get_password() {\n Ok(value) => Ok(Some(value)),\n Err(keyring::Error::NoEntry) => Ok(None),\n Err(error) => Err(format!(\n \"failed to read API key from system credential store: {error}\"\n )),\n }\n }\n fn delete(&self) -> Result<(), String> {\n match credential_entry()?.delete_credential() {\n Ok(()) | Err(keyring::Error::NoEntry) => Ok(()),\n Err(error) => Err(format!(\n \"failed to delete API key from system credential store: {error}\"\n )),\n }\n }\n\n fn set_profile(&self, profile_id: &str, value: &str) -> Result<(), String> {\n profile_credential_entry(profile_id)?\n .set_password(value.trim())\n .map_err(|error| format!(\"failed to save custom provider API key: {error}\"))\n }\n\n fn get_profile(&self, profile_id: &str) -> Result<Option<String>, String> {\n match profile_credential_entry(profile_id)?.get_password() {\n Ok(value) => Ok(Some(value)),\n Err(keyring::Error::NoEntry) => Ok(None),\n Err(error) => Err(format!(\"failed to read custom provider API key: {error}\")),\n }\n }\n\n fn delete_profile(&self, profile_id: &str) -> Result<(), String> {\n match profile_credential_entry(profile_id)?.delete_credential() {\n Ok(()) | Err(keyring::Error::NoEntry) => Ok(()),\n Err(error) => Err(format!(\"failed to delete custom provider API key: {error}\")),\n }\n }\n}\n\nfn public_ai_settings(mut settings: AISettings) -> AISettings {\n settings.api_key_configured = !settings.api_key.is_empty();\n settings.api_key.clear();\n settings.api_key_action = ApiKeyAction::Preserve;\n settings\n}\n\npub fn normalize_ai_settings(mut settings: AISettings) -> AISettings {\n if settings.preset == AIProviderPresetId::CustomOpenAICompatible {\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 .cloned()\n {\n settings.base_url = profile.base_url;\n settings.chat_path = profile.chat_path;\n settings.models_path = profile.models_path;\n settings.model = profile.model;\n settings.force_json_output = profile.supports_response_format;\n settings.enable_thinking = profile.supports_thinking;\n settings.max_tokens = profile.max_output_tokens;\n settings.extra_body_json = profile.extra_body_json;\n }\n }\n if let Some(preset) = provider_preset(settings.preset) {\n settings.provider = preset.provider_kind;\n if settings.base_url.trim().is_empty() && !preset.default_base_url.is_empty() {\n settings.base_url = preset.default_base_url.to_string();\n }\n if settings.chat_path.trim().is_empty() {\n settings.chat_path = preset.default_chat_path.to_string();\n }\n if settings\n .models_path\n .as_deref()\n .unwrap_or_default()\n .trim()\n .is_empty()\n {\n settings.models_path = preset.models_path.map(ToString::to_string);\n }\n if settings.model.trim().is_empty() && !preset.default_model.is_empty() {\n settings.model = preset.default_model.to_string();\n }\n if settings.preset == AIProviderPresetId::Ollama {\n settings.chat_path = \"/api/chat\".to_string();\n }\n }\n\n settings.base_url = settings.base_url.trim().trim_end_matches('/').to_string();\n settings.chat_path = normalize_chat_path(&settings.chat_path);\n settings.models_path = settings\n .models_path\n .as_deref()\n .map(normalize_optional_path)\n .filter(|value| !value.is_empty());\n settings.api_key = settings.api_key.trim().to_string();\n settings.model = settings.model.trim().to_string();\n settings.batch_size = settings.batch_size.clamp(1, 100);\n settings.classification_concurrency = settings.classification_concurrency.clamp(1, 4);\n if settings.provider == AIProviderKind::Ollama {\n settings.classification_concurrency = settings.classification_concurrency.min(1);\n }\n settings.timeout_seconds = settings.timeout_seconds.clamp(1, 600);\n settings.max_tokens = settings.max_tokens.clamp(1, 32_768);\n settings.temperature = settings.temperature.clamp(0.0, 2.0);\n settings.classification_mode = match settings.classification_mode.trim() {\n \"ai_first\" | \"rules_first\" | \"hybrid\" => settings.classification_mode.trim().to_string(),\n _ => \"rules_first\".to_string(),\n };\n settings.reasoning_effort = settings\n .reasoning_effort\n .as_deref()\n .map(str::trim)\n .filter(|value| !value.is_empty())\n .map(ToString::to_string);\n settings.extra_body_json = settings\n .extra_body_json\n .as_deref()\n .map(str::trim)\n .filter(|value| !value.is_empty())\n .map(ToString::to_string);\n let mut seen_profile_ids = std::collections::HashSet::new();\n settings.custom_profiles = settings\n .custom_profiles\n .into_iter()\n .filter_map(|mut profile| {\n profile.id = profile.id.trim().to_string();\n profile.name = profile.name.trim().to_string();\n if !is_valid_profile_id(&profile.id) || !seen_profile_ids.insert(profile.id.clone()) {\n return None;\n }\n profile.base_url = profile.base_url.trim().trim_end_matches('/').to_string();\n profile.chat_path = normalize_chat_path(&profile.chat_path);\n profile.models_path = profile\n .models_path\n .as_deref()\n .map(normalize_optional_path)\n .filter(|value| !value.is_empty());\n profile.model = profile.model.trim().to_string();\n profile.thinking_parameter = profile.thinking_parameter.trim().to_string();\n profile.token_parameter = profile.token_parameter.trim().to_string();\n profile.content_path = profile.content_path.trim().to_string();\n profile.reasoning_path = profile.reasoning_path.trim().to_string();\n profile.extra_body_json = profile\n .extra_body_json\n .as_deref()\n .map(str::trim)\n .filter(|value| !value.is_empty())\n .map(ToString::to_string);\n Some(profile)\n })\n .take(20)\n .collect();\n if !settings\n .active_custom_profile_id\n .as_deref()\n .map(|profile_id| {\n settings\n .custom_profiles\n .iter()\n .any(|profile| profile.id == profile_id)\n })\n .unwrap_or(false)\n {\n settings.active_custom_profile_id = None;\n }\n settings\n}\n\npub fn validate_ai_settings(settings: &AISettings, release_mode: bool) -> Result<(), String> {\n let preset = provider_preset(settings.preset)\n .ok_or_else(|| \"AI provider preset is not supported.\".to_string())?;\n if preset.provider_kind != settings.provider {\n return Err(\"AI provider and preset are incompatible.\".to_string());\n }\n if !(1..=100).contains(&settings.batch_size) {\n return Err(\"AI batch size must be between 1 and 100.\".to_string());\n }\n if !(1..=4).contains(&settings.classification_concurrency) {\n return Err(\"AI classification concurrency must be between 1 and 4.\".to_string());\n }\n if !(1..=600).contains(&settings.timeout_seconds) {\n return Err(\"AI timeout must be between 1 and 600 seconds.\".to_string());\n }\n if !(1..=32_768).contains(&settings.max_tokens) {\n return Err(\"AI max tokens must be between 1 and 32768.\".to_string());\n }\n if !settings.temperature.is_finite() || !(0.0..=2.0).contains(&settings.temperature) {\n return Err(\"AI temperature must be between 0 and 2.\".to_string());\n }\n validate_text_limit(\"model\", &settings.model, 200)?;\n if settings.custom_profiles.len() > 20 {\n return Err(\"AI custom provider profiles cannot exceed 20 entries.\".to_string());\n }\n let mut profile_ids = std::collections::HashSet::new();\n for profile in &settings.custom_profiles {\n if !is_valid_profile_id(&profile.id) || !profile_ids.insert(&profile.id) {\n return Err(\"AI custom provider profile IDs must be unique ASCII names.\".to_string());\n }\n validate_text_limit(\"custom provider profile name\", &profile.name, 120)?;\n validate_text_limit(\"custom provider profile base URL\", &profile.base_url, 2_048)?;\n validate_text_limit(\"custom provider profile chat path\", &profile.chat_path, 512)?;\n validate_text_limit(\"custom provider profile model\", &profile.model, 200)?;\n if !profile.temperature_min.is_finite()\n || !profile.temperature_max.is_finite()\n || profile.temperature_min < 0.0\n || profile.temperature_max > 2.0\n || profile.temperature_min > profile.temperature_max\n {\n return Err(\"Custom provider temperature range must stay within 0 to 2.\".to_string());\n }\n if !(1..=32_768).contains(&profile.max_output_tokens) {\n return Err(\n \"Custom provider max output tokens must be between 1 and 32768.\".to_string(),\n );\n }\n validate_provider_url(\n &AISettings {\n base_url: profile.base_url.clone(),\n chat_path: profile.chat_path.clone(),\n ..settings.clone()\n },\n release_mode,\n )?;\n validate_extra_body_json(profile.extra_body_json.as_deref())?;\n }\n if let Some(active_profile_id) = settings.active_custom_profile_id.as_deref() {\n if settings.preset != AIProviderPresetId::CustomOpenAICompatible\n || !settings\n .custom_profiles\n .iter()\n .any(|profile| profile.id == active_profile_id)\n {\n return Err(\"Active custom provider profile is not available.\".to_string());\n }\n }\n validate_text_limit(\"base URL\", &settings.base_url, 2_048)?;\n validate_text_limit(\"chat path\", &settings.chat_path, 512)?;\n if let Some(models_path) = settings.models_path.as_deref() {\n validate_text_limit(\"models path\", models_path, 512)?;\n if models_path.starts_with(\"//\")\n || models_path.contains(\"://\")\n || models_path.contains('\\\\')\n || models_path.trim().is_empty()\n {\n return Err(\"AI models path must be a relative URL path.\".to_string());\n }\n }\n if let Some(reasoning_effort) = settings.reasoning_effort.as_deref() {\n validate_text_limit(\"reasoning effort\", reasoning_effort, 64)?;\n }\n validate_provider_url(settings, release_mode)?;\n validate_extra_body_json(settings.extra_body_json.as_deref())?;\n Ok(())\n}\n\nfn validate_text_limit(label: &str, value: &str, max_len: usize) -> Result<(), String> {\n if value.len() > max_len {\n return Err(format!(\"AI {label} exceeds {max_len} characters.\"));\n }\n Ok(())\n}\n\nfn validate_provider_url(settings: &AISettings, release_mode: bool) -> Result<(), String> {\n let parsed = url::Url::parse(settings.base_url.trim())\n .map_err(|_| \"AI base URL must be a valid HTTP or HTTPS URL.\".to_string())?;\n if !matches!(parsed.scheme(), \"http\" | \"https\") {\n return Err(\"AI base URL only supports HTTP or HTTPS.\".to_string());\n }\n if !parsed.username().is_empty() || parsed.password().is_some() {\n return Err(\"AI base URL must not contain user credentials.\".to_string());\n }\n let host = parsed\n .host_str()\n .ok_or_else(|| \"AI base URL must include a host.\".to_string())?;\n let localhost = host.eq_ignore_ascii_case(\"localhost\")\n || host == \"127.0.0.1\"\n || matches!(host, \"::1\" | \"[::1]\");\n if release_mode && parsed.scheme() == \"http\" && !localhost {\n return Err(\"Release builds require HTTPS for non-local AI providers.\".to_string());\n }\n let chat_path = settings.chat_path.trim();\n if chat_path.is_empty()\n || chat_path.starts_with(\"//\")\n || chat_path.contains(\"://\")\n || chat_path.contains('\\\\')\n {\n return Err(\"AI chat path must be a relative URL path.\".to_string());\n }\n let normalized_chat_path = format!(\"/{}\", chat_path.trim_start_matches('/'));\n let joined = parsed\n .join(&normalized_chat_path)\n .map_err(|_| \"AI chat path must be a valid relative URL path.\".to_string())?;\n if joined.scheme() != parsed.scheme()\n || joined.host_str() != parsed.host_str()\n || joined.port_or_known_default() != parsed.port_or_known_default()\n {\n return Err(\"AI chat path must not change the provider origin.\".to_string());\n }\n Ok(())\n}\n\nfn validate_extra_body_json(extra: Option<&str>) -> Result<(), String> {\n let Some(extra) = extra.map(str::trim).filter(|value| !value.is_empty()) else {\n return Ok(());\n };\n if extra.len() > 16_384 {\n return Err(\"AI extra body JSON exceeds 16384 characters.\".to_string());\n }\n let value: serde_json::Value = serde_json::from_str(extra)\n .map_err(|error| format!(\"AI extra body must be valid JSON: {error}\"))?;\n let object = value\n .as_object()\n .ok_or_else(|| \"AI extra body must be a JSON object.\".to_string())?;\n const RESERVED: &[&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 \"tools\",\n \"tool_choice\",\n ];\n if let Some(field) = RESERVED.iter().find(|field| object.contains_key(**field)) {\n return Err(format!(\n \"AI extra body cannot override internal field: {field}.\"\n ));\n }\n if json_depth(&value) > 8 {\n return Err(\"AI extra body JSON nesting exceeds 8 levels.\".to_string());\n }\n Ok(())\n}\n\nfn json_depth(value: &serde_json::Value) -> usize {\n match value {\n serde_json::Value::Array(values) => {\n 1 + values.iter().map(json_depth).max().unwrap_or_default()\n }\n serde_json::Value::Object(values) => {\n 1 + values.values().map(json_depth).max().unwrap_or_default()\n }\n _ => 0,\n }\n}\n\npub fn test_ai_provider_connection_for_settings(\n settings: AISettings,\n) -> Result<AIConnectionTestResult, String> {\n let settings = normalize_ai_settings(settings);\n validate_ai_settings(&settings, !cfg!(debug_assertions))?;\n let started = Instant::now();\n\n let mut result = match settings.provider {\n AIProviderKind::OpenAICompatible => {\n OpenAICompatibleProvider::new(settings.clone()).test_connection()\n }\n AIProviderKind::Ollama => OllamaProvider::new(settings.clone()).test_connection(),\n }\n .map_err(|error| sanitize_ai_error(error.to_string(), &settings.api_key))?;\n result.elapsed_ms = started.elapsed().as_millis();\n Ok(result)\n}\n\npub(crate) fn provider_for_settings(settings: &AISettings) -> Box<dyn AIProvider> {\n match settings.provider {\n AIProviderKind::OpenAICompatible => {\n Box::new(OpenAICompatibleProvider::new(settings.clone()))\n }\n AIProviderKind::Ollama => Box::new(OllamaProvider::new(settings.clone())),\n }\n}\n\npub fn list_ai_models_for_settings(settings: AISettings) -> Result<Vec<AIModelInfo>, String> {\n let settings = normalize_ai_settings(settings);\n validate_ai_settings(&settings, !cfg!(debug_assertions))?;\n let provider = provider_for_settings(&settings);\n let preset = provider_preset(settings.preset);\n let mut models = match provider.discover_models() {\n Ok(models) => models,\n Err(_error)\n if preset\n .as_ref()\n .map(|preset| preset.models_path.is_none())\n .unwrap_or(false) =>\n {\n Vec::new()\n }\n Err(error) => return Err(sanitize_ai_error(error.to_string(), &settings.api_key)),\n };\n if let Some(preset) = preset {\n let mut seen = models\n .iter()\n .map(|model| model.id.clone())\n .collect::<std::collections::HashSet<_>>();\n for model in preset.suggested_models {\n if !model.is_empty() && seen.insert((*model).to_string()) {\n models.push(AIModelInfo {\n id: (*model).to_string(),\n owned_by: None,\n discovered: false,\n });\n }\n }\n }\n Ok(models)\n}\n\n#[tauri::command]\npub fn get_ai_settings(db: State<'_, Database>) -> Result<AISettings, String> {\n get_ai_settings_for_db(db.inner())\n .map(public_ai_settings)\n .map_err(|error| error.to_string())\n}\n\n#[tauri::command]\npub fn save_ai_settings<R: Runtime>(\n window: WebviewWindow<R>,\n db: State<'_, Database>,\n settings: AISettings,\n) -> Result<AISettings, String> {\n require_main_window(&window)?;\n save_ai_settings_for_db(db.inner(), &settings)\n .map(public_ai_settings)\n .map_err(|error| error.to_string())\n}\n\n#[tauri::command]\npub fn list_ai_provider_presets() -> Vec<AIProviderPreset> {\n all_provider_presets()\n}\n\n#[tauri::command]\npub async fn test_ai_provider_connection(\n db: State<'_, Database>,\n settings: Option<AISettings>,\n) -> Result<AIConnectionTestResult, String> {\n let db = db.inner().clone();\n tauri::async_runtime::spawn_blocking(move || {\n let settings = match settings {\n Some(mut settings) => {\n if settings.api_key.trim().is_empty() {\n settings.api_key = get_ai_settings_for_db(&db)\n .map_err(|error| error.to_string())?\n .api_key;\n }\n normalize_ai_settings(settings)\n }\n None => get_ai_settings_for_db(&db).map_err(|error| error.to_string())?,\n };\n test_ai_provider_connection_for_settings(settings)\n })\n .await\n .map_err(|error| error.to_string())?\n}\n\n#[tauri::command]\npub async fn list_ai_models(\n db: State<'_, Database>,\n settings: Option<AISettings>,\n) -> Result<Vec<AIModelInfo>, String> {\n let db = db.inner().clone();\n tauri::async_runtime::spawn_blocking(move || {\n let settings = match settings {\n Some(mut settings) => {\n if settings.api_key.trim().is_empty() {\n settings.api_key = get_ai_settings_for_db(&db)\n .map_err(|error| error.to_string())?\n .api_key;\n }\n normalize_ai_settings(settings)\n }\n None => get_ai_settings_for_db(&db).map_err(|error| error.to_string())?,\n };\n list_ai_models_for_settings(settings)\n })\n .await\n .map_err(|error| error.to_string())?\n}\n\nfn normalize_chat_path(value: &str) -> String {\n let trimmed = value.trim();\n if trimmed.is_empty() {\n return \"/chat/completions\".to_string();\n }\n format!(\"/{}\", trimmed.trim_start_matches('/'))\n}\n\nfn normalize_optional_path(value: &str) -> String {\n let trimmed = value.trim();\n if trimmed.is_empty() {\n return String::new();\n }\n format!(\"/{}\", trimmed.trim_start_matches('/'))\n}\n\nfn sanitize_ai_error(message: String, api_key: &str) -> String {\n let api_key = api_key.trim();\n if api_key.is_empty() {\n message\n } else {\n message.replace(api_key, \"[redacted]\")\n }\n}\n\n#[cfg(test)]\nmod validation_tests {\n use super::*;\n use std::{\n path::PathBuf,\n sync::{\n atomic::{AtomicUsize, Ordering},\n Arc,\n },\n thread,\n time::Duration,\n };\n\n #[test]\n fn public_ai_settings_reports_configuration_without_exposing_the_key() {\n let public = public_ai_settings(AISettings {\n api_key: \"top-secret\".to_string(),\n api_key_action: ApiKeyAction::Replace,\n ..AISettings::default()\n });\n\n assert!(public.api_key.is_empty());\n assert!(public.api_key_configured);\n assert_eq!(public.api_key_action, ApiKeyAction::Preserve);\n let json = serde_json::to_value(public).expect(\"serialize public settings\");\n assert_eq!(json[\"apiKey\"], \"\");\n assert_eq!(json[\"apiKeyConfigured\"], true);\n assert!(json.get(\"apiKeyAction\").is_none());\n assert!(!json.to_string().contains(\"top-secret\"));\n }\n\n #[test]\n fn ai_numeric_bounds_accept_limits_and_reject_out_of_range_values() {\n let mut settings = AISettings {\n batch_size: 100,\n classification_concurrency: 4,\n timeout_seconds: 600,\n max_tokens: 32_768,\n temperature: 2.0,\n ..AISettings::default()\n };\n assert!(validate_ai_settings(&settings, true).is_ok());\n\n settings.batch_size = 101;\n assert!(validate_ai_settings(&settings, true).is_err());\n settings.batch_size = 0;\n assert!(validate_ai_settings(&settings, true).is_err());\n settings.batch_size = 1;\n settings.classification_concurrency = 5;\n assert!(validate_ai_settings(&settings, true).is_err());\n settings.classification_concurrency = 0;\n assert!(validate_ai_settings(&settings, true).is_err());\n settings.classification_concurrency = 1;\n settings.timeout_seconds = 601;\n assert!(validate_ai_settings(&settings, true).is_err());\n settings.timeout_seconds = 0;\n assert!(validate_ai_settings(&settings, true).is_err());\n settings.timeout_seconds = 1;\n settings.max_tokens = 32_769;\n assert!(validate_ai_settings(&settings, true).is_err());\n settings.max_tokens = 0;\n assert!(validate_ai_settings(&settings, true).is_err());\n settings.max_tokens = 1;\n settings.temperature = -0.1;\n assert!(validate_ai_settings(&settings, true).is_err());\n settings.temperature = 2.1;\n assert!(validate_ai_settings(&settings, true).is_err());\n settings.temperature = f32::NAN;\n assert!(validate_ai_settings(&settings, true).is_err());\n }\n\n #[test]\n fn ai_url_policy_rejects_unsafe_schemes_userinfo_and_release_http() {\n let mut settings = AISettings::default();\n for url in [\n \"file:///tmp/model\",\n \"ftp://example.com\",\n \"data:text/plain,x\",\n ] {\n settings.base_url = url.to_string();\n assert!(validate_ai_settings(&settings, true).is_err(), \"{url}\");\n }\n settings.base_url = \"https://user:secret@example.com\".to_string();\n assert!(validate_ai_settings(&settings, true).is_err());\n settings.base_url = \"http://example.com\".to_string();\n assert!(validate_ai_settings(&settings, true).is_err());\n\n for url in [\n \"http://localhost:11434\",\n \"http://127.0.0.1:11434\",\n \"http://[::1]:11434\",\n ] {\n settings.base_url = url.to_string();\n assert!(validate_ai_settings(&settings, true).is_ok(), \"{url}\");\n }\n }\n\n #[test]\n fn ai_provider_preset_and_chat_path_cannot_cross_security_boundaries() {\n let mut settings = AISettings {\n provider: AIProviderKind::Ollama,\n ..AISettings::default()\n };\n assert!(validate_ai_settings(&settings, true).is_err());\n\n settings.provider = AIProviderKind::OpenAICompatible;\n for chat_path in [\n \"https://evil.example/chat\",\n \"//evil.example/chat\",\n \"\\\\\\\\evil\\\\chat\",\n ] {\n settings.chat_path = chat_path.to_string();\n assert!(\n validate_ai_settings(&settings, true).is_err(),\n \"{chat_path}\"\n );\n }\n }\n\n #[test]\n fn ai_extra_body_requires_bounded_safe_json_object() {\n let mut settings = AISettings::default();\n for json in [\n \"not-json\",\n \"[]\",\n r#\"{\"model\":\"override\"}\"#,\n r#\"{\"messages\":[]}\"#,\n r#\"{\"stream\":true}\"#,\n r#\"{\"max_completion_tokens\":999999}\"#,\n r#\"{\"response_format\":{\"type\":\"text\"}}\"#,\n r#\"{\"thinking\":{\"type\":\"disabled\"}}\"#,\n r#\"{\"reasoning_effort\":\"high\"}\"#,\n ] {\n settings.extra_body_json = Some(json.to_string());\n assert!(validate_ai_settings(&settings, true).is_err(), \"{json}\");\n }\n settings.extra_body_json = Some(r#\"{\"safe_extension\":true}\"#.to_string());\n assert!(validate_ai_settings(&settings, true).is_ok());\n settings.extra_body_json = Some(format!(r#\"{{\"value\":\"{}\"}}\"#, \"x\".repeat(16_384)));\n assert!(validate_ai_settings(&settings, true).is_err());\n settings.extra_body_json =\n Some(r#\"{\"a\":{\"b\":{\"c\":{\"d\":{\"e\":{\"f\":{\"g\":{\"h\":{\"i\":1}}}}}}}}}}\"#.to_string());\n assert!(validate_ai_settings(&settings, true).is_err());\n }\n\n #[test]\n fn ai_text_fields_enforce_length_limits() {\n let mut settings = AISettings {\n model: \"x\".repeat(201),\n ..AISettings::default()\n };\n assert!(validate_ai_settings(&settings, true).is_err());\n settings.model = \"model\".to_string();\n settings.reasoning_effort = Some(\"x\".repeat(65));\n assert!(validate_ai_settings(&settings, true).is_err());\n }\n\n #[derive(Default)]\n struct ConcurrentCredentialStore {\n value: Mutex<Option<String>>,\n active: AtomicUsize,\n max_active: AtomicUsize,\n }\n\n impl ConcurrentCredentialStore {\n fn begin(&self) {\n let active = self.active.fetch_add(1, Ordering::SeqCst) + 1;\n self.max_active.fetch_max(active, Ordering::SeqCst);\n thread::sleep(Duration::from_millis(10));\n }\n\n fn end(&self) {\n self.active.fetch_sub(1, Ordering::SeqCst);\n }\n }\n\n impl CredentialStore for ConcurrentCredentialStore {\n fn set(&self, value: &str) -> Result<(), String> {\n self.begin();\n let result = self\n .value\n .lock()\n .map_err(|_| \"credential test lock poisoned\".to_string())\n .map(|mut current| *current = Some(value.to_string()));\n self.end();\n result\n }\n\n fn get(&self) -> Result<Option<String>, String> {\n self.begin();\n let result = self\n .value\n .lock()\n .map(|current| current.clone())\n .map_err(|_| \"credential test lock poisoned\".to_string());\n self.end();\n result\n }\n\n fn delete(&self) -> Result<(), String> {\n self.begin();\n let result = self\n .value\n .lock()\n .map_err(|_| \"credential test lock poisoned\".to_string())\n .map(|mut current| *current = None);\n self.end();\n result\n }\n }\n\n fn concurrency_test_db_path(label: &str) -> PathBuf {\n std::env::temp_dir().join(format!(\n \"zen-canvas-ai-settings-{label}-{}-{}.sqlite3\",\n std::process::id(),\n uuid::Uuid::new_v4()\n ))\n }\n\n #[test]\n fn concurrent_credential_saves_are_serialized_as_transactions() {\n let credentials = Arc::new(ConcurrentCredentialStore::default());\n let first = AISettings {\n api_key: \"first-test-key\".to_string(),\n api_key_action: ApiKeyAction::Replace,\n ..AISettings::default()\n };\n let second = AISettings {\n api_key: \"second-test-key\".to_string(),\n api_key_action: ApiKeyAction::Replace,\n ..AISettings::default()\n };\n\n let first_path = concurrency_test_db_path(\"first\");\n let second_path = concurrency_test_db_path(\"second\");\n let first_thread_path = first_path.clone();\n let first_credentials = Arc::clone(&credentials);\n let first_thread = thread::spawn(move || {\n let db = Database::open(&first_thread_path).expect(\"first database\");\n save_ai_settings_with_store(&db, &first, first_credentials.as_ref())\n });\n let second_thread_path = second_path.clone();\n let second_credentials = Arc::clone(&credentials);\n let second_thread = thread::spawn(move || {\n let db = Database::open(&second_thread_path).expect(\"second database\");\n save_ai_settings_with_store(&db, &second, second_credentials.as_ref())\n });\n\n first_thread\n .join()\n .expect(\"first save thread\")\n .expect(\"first save\");\n second_thread\n .join()\n .expect(\"second save thread\")\n .expect(\"second save\");\n\n assert_eq!(credentials.max_active.load(Ordering::SeqCst), 1);\n let _ = std::fs::remove_file(first_path);\n let _ = std::fs::remove_file(second_path);\n }\n}\n"}}}} |