Files
OpenCode 68a9071cf1 feat: reconcile local master with origin (exa/hot-platform hunters, leaks ledger, latest results)
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.
2026-08-07 12:53:47 +08:00

1 line
468 KiB
JSON
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
{"repo": "ardenzc/zen-canvas", "files": {"src-tauri/src/ai/registry.rs": {"9ea69d29143b994c8632747ab647f59637dfe324": {"t": 1785745843.540229, "n": 23417, "h": "1f108b13a19604d0", "c": "use serde::{Deserialize, Serialize};\n\nuse super::schema::{AIProviderKind, AIProviderPresetId};\n\n#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]\n#[serde(rename_all = \"snake_case\")]\npub enum AIAuthKind {\n None,\n BearerApiKey,\n ApiKeyHeader,\n QianfanAkSk,\n}\n\n#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]\n#[serde(rename_all = \"snake_case\")]\npub enum AITokenParameter {\n MaxTokens,\n MaxCompletionTokens,\n}\n\n#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]\n#[serde(rename_all = \"snake_case\")]\npub enum AIThinkingStrategy {\n None,\n DeepSeekThinkingObject,\n GenericThinkingObject,\n EnableThinkingBoolean,\n ReasoningEffort,\n MiniMaxReasoningSplit,\n PromptOnly,\n}\n\n#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]\n#[serde(rename_all = \"snake_case\")]\npub enum ResponseFieldPath {\n ChoicesMessageContent,\n ChoicesMessageOutputText,\n ChoicesMessageText,\n ChoicesMessageReasoningContent,\n ChoicesMessageReasoningDetails,\n RootOutputText,\n RootContent,\n}\n\n#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]\n#[serde(rename_all = \"camelCase\")]\npub struct AIProviderCapabilities {\n pub supports_model_discovery: bool,\n pub supports_response_format_json_object: bool,\n pub supports_json_schema: bool,\n pub supports_thinking: bool,\n pub supports_thinking_toggle: bool,\n pub supports_reasoning_effort: bool,\n pub supports_usage: bool,\n pub supports_streaming: bool,\n}\n\n#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]\n#[serde(rename_all = \"camelCase\")]\npub struct AIParameterProfile {\n pub temperature_min: f64,\n pub temperature_max: f64,\n pub default_temperature: f64,\n pub max_output_tokens: Option<u32>,\n pub token_parameter: AITokenParameter,\n pub thinking_strategy: AIThinkingStrategy,\n}\n\n#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]\n#[serde(rename_all = \"camelCase\")]\npub struct AIResponseProfile {\n pub content_paths: &'static [ResponseFieldPath],\n pub reasoning_paths: &'static [ResponseFieldPath],\n pub finish_reason_paths: &'static [ResponseFieldPath],\n pub usage_paths: &'static [ResponseFieldPath],\n pub strip_think_tags: bool,\n}\n\n#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]\n#[serde(rename_all = \"camelCase\")]\npub struct AIEndpointVariant {\n pub id: &'static str,\n pub label: &'static str,\n pub base_url: &'static str,\n}\n\n#[derive(Debug, Clone, Copy, PartialEq, Serialize)]\n#[serde(rename_all = \"camelCase\")]\npub struct AIProviderDescriptor {\n pub id: AIProviderPresetId,\n pub label: &'static str,\n pub provider_kind: AIProviderKind,\n pub default_base_url: &'static str,\n pub default_chat_path: &'static str,\n pub models_path: Option<&'static str>,\n pub default_model: &'static str,\n pub suggested_models: &'static [&'static str],\n pub auth_kind: AIAuthKind,\n pub capabilities: AIProviderCapabilities,\n pub parameter_profile: AIParameterProfile,\n pub response_profile: AIResponseProfile,\n pub endpoint_variants: &'static [AIEndpointVariant],\n pub api_key_env_hint: &'static str,\n pub docs_url: Option<&'static str>,\n}\n\nconst OPENAI_CONTENT_PATHS: &[ResponseFieldPath] = &[\n ResponseFieldPath::ChoicesMessageContent,\n ResponseFieldPath::ChoicesMessageOutputText,\n ResponseFieldPath::ChoicesMessageText,\n ResponseFieldPath::RootOutputText,\n ResponseFieldPath::RootContent,\n];\nconst OPENAI_REASONING_PATHS: &[ResponseFieldPath] = &[\n ResponseFieldPath::ChoicesMessageReasoningContent,\n ResponseFieldPath::ChoicesMessageReasoningDetails,\n];\nconst OPENAI_FINISH_PATHS: &[ResponseFieldPath] = &[];\nconst OPENAI_USAGE_PATHS: &[ResponseFieldPath] = &[];\nconst EMPTY_ENDPOINTS: &[AIEndpointVariant] = &[];\nconst DEEPSEEK_MODELS: &[&str] = &[\"deepseek-v4-flash\", \"deepseek-v4-pro\"];\nconst KIMI_MODELS: &[&str] = &[\"kimi-k2.6\", \"kimi-k2-thinking\", \"moonshot-v1-128k\"];\nconst QWEN_MODELS: &[&str] = &[\"qwen-plus\", \"qwen3.6-plus\", \"qwen3.5-plus\", \"qwen3-vl-plus\"];\nconst GLM_MODELS: &[&str] = &[\"glm-5.2\", \"glm-5\", \"glm-4.7\", \"glm-4.5\"];\nconst MINIMAX_MODELS: &[&str] = &[\"MiniMax-M2.5\", \"MiniMax-M2.1\", \"MiniMax-M2\"];\nconst DOUBAO_MODELS: &[&str] = &[\"ep-2024-...\", \"doubao-seed-1-6-250615\"];\nconst HUNYUAN_MODELS: &[&str] = &[\"hunyuan-turbos\", \"hunyuan-pro\"];\nconst STEPFUN_MODELS: &[&str] = &[\"step-3.5-flash\", \"step-3.5-turbo\"];\nconst OLLAMA_MODELS: &[&str] = &[\"qwen3:8b\", \"llama3.2:3b\"];\n\nconst DASHSCOPE_ENDPOINTS: &[AIEndpointVariant] = &[\n AIEndpointVariant {\n id: \"cn-beijing\",\n label: \"中国内地(北京)\",\n base_url: \"https://dashscope.aliyuncs.com/compatible-mode/v1\",\n },\n AIEndpointVariant {\n id: \"us-virginia\",\n label: \"美国(弗吉尼亚)\",\n base_url: \"https://dashscope-us.aliyuncs.com/compatible-mode/v1\",\n },\n AIEndpointVariant {\n id: \"singapore-workspace\",\n label: \"新加坡(Workspace Endpoint)\",\n base_url: \"https://{WorkspaceId}.ap-southeast-1.maas.aliyuncs.com/compatible-mode/v1\",\n },\n AIEndpointVariant {\n id: \"japan-workspace\",\n label: \"日本(Workspace Endpoint)\",\n base_url: \"https://{WorkspaceId}.ap-northeast-1.maas.aliyuncs.com/compatible-mode/v1\",\n },\n];\n\nconst OPENAI_RESPONSE_PROFILE: AIResponseProfile = AIResponseProfile {\n content_paths: OPENAI_CONTENT_PATHS,\n reasoning_paths: OPENAI_REASONING_PATHS,\n finish_reason_paths: OPENAI_FINISH_PATHS,\n usage_paths: OPENAI_USAGE_PATHS,\n strip_think_tags: false,\n};\nconst MINIMAX_RESPONSE_PROFILE: AIResponseProfile = AIResponseProfile {\n content_paths: OPENAI_CONTENT_PATHS,\n reasoning_paths: OPENAI_REASONING_PATHS,\n finish_reason_paths: OPENAI_FINISH_PATHS,\n usage_paths: OPENAI_USAGE_PATHS,\n strip_think_tags: true,\n};\n\nconst PROMPT_ONLY: AIProviderCapabilities = AIProviderCapabilities {\n supports_model_discovery: true,\n supports_response_format_json_object: false,\n supports_json_schema: false,\n supports_thinking: false,\n supports_thinking_toggle: false,\n supports_reasoning_effort: false,\n supports_usage: true,\n supports_streaming: true,\n};\n\nconst DEFAULT_OPENAI_PARAMETERS: AIParameterProfile = AIParameterProfile {\n temperature_min: 0.0,\n temperature_max: 2.0,\n default_temperature: 0.0,\n max_output_tokens: None,\n token_parameter: AITokenParameter::MaxTokens,\n thinking_strategy: AIThinkingStrategy::PromptOnly,\n};\nconst QWEN_CAPABILITIES: AIProviderCapabilities = AIProviderCapabilities {\n supports_model_discovery: true,\n supports_response_format_json_object: true,\n supports_json_schema: false,\n supports_thinking: true,\n supports_thinking_toggle: true,\n supports_reasoning_effort: false,\n supports_usage: true,\n supports_streaming: true,\n};\n\npub static PROVIDER_REGISTRY: &[AIProviderDescriptor] = &[\n AIProviderDescriptor {\n id: AIProviderPresetId::DeepSeek,\n label: \"DeepSeek\",\n provider_kind: AIProviderKind::OpenAICompatible,\n default_base_url: \"https://api.deepseek.com\",\n default_chat_path: \"/chat/completions\",\n models_path: Some(\"/models\"),\n default_model: \"deepseek-v4-flash\",\n suggested_models: DEEPSEEK_MODELS,\n auth_kind: AIAuthKind::BearerApiKey,\n capabilities: AIProviderCapabilities {\n supports_model_discovery: true,\n supports_response_format_json_object: true,\n supports_json_schema: false,\n supports_thinking: true,\n supports_thinking_toggle: true,\n supports_reasoning_effort: true,\n supports_usage: true,\n supports_streaming: true,\n },\n parameter_profile: AIParameterProfile {\n temperature_min: 0.0,\n temperature_max: 2.0,\n default_temperature: 0.0,\n max_output_tokens: Some(8192),\n token_parameter: AITokenParameter::MaxTokens,\n thinking_strategy: AIThinkingStrategy::DeepSeekThinkingObject,\n },\n response_profile: OPENAI_RESPONSE_PROFILE,\n endpoint_variants: EMPTY_ENDPOINTS,\n api_key_env_hint: \"DEEPSEEK_API_KEY\",\n docs_url: Some(\"https://api-docs.deepseek.com/\"),\n },\n AIProviderDescriptor {\n id: AIProviderPresetId::Kimi,\n label: \"Kimi / Moonshot\",\n provider_kind: AIProviderKind::OpenAICompatible,\n default_base_url: \"https://api.moonshot.ai/v1\",\n default_chat_path: \"/chat/completions\",\n models_path: Some(\"/models\"),\n default_model: \"kimi-k2.6\",\n suggested_models: KIMI_MODELS,\n auth_kind: AIAuthKind::BearerApiKey,\n capabilities: AIProviderCapabilities {\n supports_model_discovery: true,\n supports_response_format_json_object: true,\n supports_json_schema: false,\n supports_thinking: true,\n supports_thinking_toggle: true,\n supports_reasoning_effort: false,\n supports_usage: true,\n supports_streaming: true,\n },\n parameter_profile: AIParameterProfile {\n temperature_min: 0.0,\n temperature_max: 1.0,\n default_temperature: 0.0,\n max_output_tokens: Some(32768),\n token_parameter: AITokenParameter::MaxTokens,\n thinking_strategy: AIThinkingStrategy::GenericThinkingObject,\n },\n response_profile: OPENAI_RESPONSE_PROFILE,\n endpoint_variants: EMPTY_ENDPOINTS,\n api_key_env_hint: \"MOONSHOT_API_KEY\",\n docs_url: Some(\"https://platform.kimi.ai/docs\"),\n },\n AIProviderDescriptor {\n id: AIProviderPresetId::QwenDashScope,\n label: \"Qwen / 阿里云百炼\",\n provider_kind: AIProviderKind::OpenAICompatible,\n default_base_url: \"https://dashscope.aliyuncs.com/compatible-mode/v1\",\n default_chat_path: \"/chat/completions\",\n models_path: Some(\"/models\"),\n default_model: \"qwen-plus\",\n suggested_models: QWEN_MODELS,\n auth_kind: AIAuthKind::BearerApiKey,\n capabilities: QWEN_CAPABILITIES,\n parameter_profile: AIParameterProfile {\n temperature_min: 0.0,\n temperature_max: 2.0,\n default_temperature: 0.0,\n max_output_tokens: Some(32768),\n token_parameter: AITokenParameter::MaxTokens,\n thinking_strategy: AIThinkingStrategy::EnableThinkingBoolean,\n },\n response_profile: OPENAI_RESPONSE_PROFILE,\n endpoint_variants: DASHSCOPE_ENDPOINTS,\n api_key_env_hint: \"DASHSCOPE_API_KEY\",\n docs_url: Some(\"https://help.aliyun.com/model-studio/\"),\n },\n AIProviderDescriptor {\n id: AIProviderPresetId::ZhipuGlm,\n label: \"智谱 GLM\",\n provider_kind: AIProviderKind::OpenAICompatible,\n default_base_url: \"https://open.bigmodel.cn/api/paas/v4\",\n default_chat_path: \"/chat/completions\",\n models_path: None,\n default_model: \"glm-5.2\",\n suggested_models: GLM_MODELS,\n auth_kind: AIAuthKind::BearerApiKey,\n capabilities: AIProviderCapabilities {\n supports_model_discovery: false,\n supports_response_format_json_object: true,\n supports_json_schema: false,\n supports_thinking: true,\n supports_thinking_toggle: true,\n supports_reasoning_effort: true,\n supports_usage: true,\n supports_streaming: true,\n },\n parameter_profile: AIParameterProfile {\n temperature_min: 0.0,\n temperature_max: 2.0,\n default_temperature: 0.0,\n max_output_tokens: Some(32768),\n token_parameter: AITokenParameter::MaxTokens,\n thinking_strategy: AIThinkingStrategy::GenericThinkingObject,\n },\n response_profile: OPENAI_RESPONSE_PROFILE,\n endpoint_variants: EMPTY_ENDPOINTS,\n api_key_env_hint: \"ZHIPUAI_API_KEY\",\n docs_url: Some(\"https://docs.bigmodel.cn/\"),\n },\n AIProviderDescriptor {\n id: AIProviderPresetId::DoubaoArk,\n label: \"豆包 / 火山方舟\",\n provider_kind: AIProviderKind::OpenAICompatible,\n default_base_url: \"https://ark.cn-beijing.volces.com/api/v3\",\n default_chat_path: \"/chat/completions\",\n models_path: Some(\"/models\"),\n default_model: \"\",\n suggested_models: DOUBAO_MODELS,\n auth_kind: AIAuthKind::BearerApiKey,\n capabilities: PROMPT_ONLY,\n parameter_profile: AIParameterProfile {\n temperature_min: 0.0,\n temperature_max: 2.0,\n default_temperature: 0.0,\n max_output_tokens: Some(32768),\n token_parameter: AITokenParameter::MaxTokens,\n thinking_strategy: AIThinkingStrategy::PromptOnly,\n },\n response_profile: OPENAI_RESPONSE_PROFILE,\n endpoint_variants: EMPTY_ENDPOINTS,\n api_key_env_hint: \"ARK_API_KEY\",\n docs_url: Some(\"https://www.volcengine.com/docs/82379\"),\n },\n AIProviderDescriptor {\n id: AIProviderPresetId::Minimax,\n label: \"MiniMax\",\n provider_kind: AIProviderKind::OpenAICompatible,\n default_base_url: \"https://api.minimaxi.com/v1\",\n default_chat_path: \"/chat/completions\",\n models_path: Some(\"/models\"),\n default_model: \"MiniMax-M2.5\",\n suggested_models: MINIMAX_MODELS,\n auth_kind: AIAuthKind::BearerApiKey,\n capabilities: AIProviderCapabilities {\n supports_model_discovery: true,\n supports_response_format_json_object: true,\n supports_json_schema: false,\n supports_thinking: true,\n supports_thinking_toggle: true,\n supports_reasoning_effort: false,\n supports_usage: true,\n supports_streaming: true,\n },\n parameter_profile: AIParameterProfile {\n temperature_min: 0.0,\n temperature_max: 1.0,\n default_temperature: 0.0,\n max_output_tokens: Some(32768),\n token_parameter: AITokenParameter::MaxCompletionTokens,\n thinking_strategy: AIThinkingStrategy::MiniMaxReasoningSplit,\n },\n response_profile: MINIMAX_RESPONSE_PROFILE,\n endpoint_variants: EMPTY_ENDPOINTS,\n api_key_env_hint: \"MINIMAX_API_KEY\",\n docs_url: Some(\"https://platform.minimaxi.com/document\"),\n },\n AIProviderDescriptor {\n id: AIProviderPresetId::Hunyuan,\n label: \"腾讯混元\",\n provider_kind: AIProviderKind::OpenAICompatible,\n default_base_url: \"https://api.hunyuan.cloud.tencent.com/v1\",\n default_chat_path: \"/chat/completions\",\n models_path: Some(\"/models\"),\n default_model: \"hunyuan-turbos\",\n suggested_models: HUNYUAN_MODELS,\n auth_kind: AIAuthKind::BearerApiKey,\n capabilities: PROMPT_ONLY,\n parameter_profile: DEFAULT_OPENAI_PARAMETERS,\n response_profile: OPENAI_RESPONSE_PROFILE,\n endpoint_variants: EMPTY_ENDPOINTS,\n api_key_env_hint: \"HUNYUAN_API_KEY\",\n docs_url: Some(\"https://cloud.tencent.com/document/product/1729\"),\n },\n AIProviderDescriptor {\n id: AIProviderPresetId::Siliconflow,\n label: \"SiliconFlow / 硅基流动\",\n provider_kind: AIProviderKind::OpenAICompatible,\n default_base_url: \"https://api.siliconflow.cn/v1\",\n default_chat_path: \"/chat/completions\",\n models_path: Some(\"/models\"),\n default_model: \"\",\n suggested_models: &[],\n auth_kind: AIAuthKind::BearerApiKey,\n capabilities: PROMPT_ONLY,\n parameter_profile: DEFAULT_OPENAI_PARAMETERS,\n response_profile: OPENAI_RESPONSE_PROFILE,\n endpoint_variants: EMPTY_ENDPOINTS,\n api_key_env_hint: \"SILICONFLOW_API_KEY\",\n docs_url: Some(\"https://docs.siliconflow.cn/\"),\n },\n AIProviderDescriptor {\n id: AIProviderPresetId::BaiduQianfan,\n label: \"百度千帆 / 文心\",\n provider_kind: AIProviderKind::OpenAICompatible,\n default_base_url: \"https://qianfan.baidubce.com/v2\",\n default_chat_path: \"/chat/completions\",\n models_path: None,\n default_model: \"ernie-4.5-turbo-32k\",\n suggested_models: &[\"ernie-4.5-turbo-32k\", \"ernie-speed-128k\"],\n auth_kind: AIAuthKind::BearerApiKey,\n capabilities: PROMPT_ONLY,\n parameter_profile: DEFAULT_OPENAI_PARAMETERS,\n response_profile: OPENAI_RESPONSE_PROFILE,\n endpoint_variants: EMPTY_ENDPOINTS,\n api_key_env_hint: \"QIANFAN_API_KEY\",\n docs_url: Some(\"https://cloud.baidu.com/doc/WENXINWORKSHOP/\"),\n },\n AIProviderDescriptor {\n id: AIProviderPresetId::Baichuan,\n label: \"百川智能(兼容入口)\",\n provider_kind: AIProviderKind::OpenAICompatible,\n default_base_url: \"\",\n default_chat_path: \"/chat/completions\",\n models_path: None,\n default_model: \"\",\n suggested_models: &[\"Baichuan4-Turbo\"],\n auth_kind: AIAuthKind::BearerApiKey,\n capabilities: PROMPT_ONLY,\n parameter_profile: DEFAULT_OPENAI_PARAMETERS,\n response_profile: OPENAI_RESPONSE_PROFILE,\n endpoint_variants: EMPTY_ENDPOINTS,\n api_key_env_hint: \"BAICHUAN_API_KEY\",\n docs_url: Some(\"https://platform.baichuan-ai.com/docs/api\"),\n },\n AIProviderDescriptor {\n id: AIProviderPresetId::StepFun,\n label: \"阶跃星辰 / StepFun\",\n provider_kind: AIProviderKind::OpenAICompatible,\n default_base_url: \"https://api.stepfun.com/v1\",\n default_chat_path: \"/chat/completions\",\n models_path: Some(\"/models\"),\n default_model: \"step-3.5-flash\",\n suggested_models: STEPFUN_MODELS,\n auth_kind: AIAuthKind::BearerApiKey,\n capabilities: PROMPT_ONLY,\n parameter_profile: DEFAULT_OPENAI_PARAMETERS,\n response_profile: OPENAI_RESPONSE_PROFILE,\n endpoint_variants: EMPTY_ENDPOINTS,\n api_key_env_hint: \"STEPFUN_API_KEY\",\n docs_url: Some(\"https://platform.stepfun.com/docs\"),\n },\n AIProviderDescriptor {\n id: AIProviderPresetId::Yi,\n label: \"零一万物 / Yi(兼容平台)\",\n provider_kind: AIProviderKind::OpenAICompatible,\n default_base_url: \"\",\n default_chat_path: \"/chat/completions\",\n models_path: None,\n default_model: \"\",\n suggested_models: &[\"yi-large\"],\n auth_kind: AIAuthKind::BearerApiKey,\n capabilities: PROMPT_ONLY,\n parameter_profile: DEFAULT_OPENAI_PARAMETERS,\n response_profile: OPENAI_RESPONSE_PROFILE,\n endpoint_variants: EMPTY_ENDPOINTS,\n api_key_env_hint: \"YI_API_KEY\",\n docs_url: Some(\"https://platform.lingyiwanwu.com/\"),\n },\n AIProviderDescriptor {\n id: AIProviderPresetId::CustomOpenAICompatible,\n label: \"自定义 OpenAI-compatible\",\n provider_kind: AIProviderKind::OpenAICompatible,\n default_base_url: \"\",\n default_chat_path: \"/chat/completions\",\n models_path: Some(\"/models\"),\n default_model: \"\",\n suggested_models: &[],\n auth_kind: AIAuthKind::BearerApiKey,\n capabilities: PROMPT_ONLY,\n parameter_profile: DEFAULT_OPENAI_PARAMETERS,\n response_profile: OPENAI_RESPONSE_PROFILE,\n endpoint_variants: EMPTY_ENDPOINTS,\n api_key_env_hint: \"OPENAI_COMPATIBLE_API_KEY\",\n docs_url: None,\n },\n AIProviderDescriptor {\n id: AIProviderPresetId::Ollama,\n label: \"Ollama 本地模型\",\n provider_kind: AIProviderKind::Ollama,\n default_base_url: \"http://localhost:11434\",\n default_chat_path: \"/api/chat\",\n models_path: Some(\"/api/tags\"),\n default_model: \"qwen3:8b\",\n suggested_models: OLLAMA_MODELS,\n auth_kind: AIAuthKind::None,\n capabilities: AIProviderCapabilities {\n supports_model_discovery: true,\n supports_response_format_json_object: false,\n supports_json_schema: false,\n supports_thinking: true,\n supports_thinking_toggle: true,\n supports_reasoning_effort: false,\n supports_usage: true,\n supports_streaming: true,\n },\n parameter_profile: AIParameterProfile {\n temperature_min: 0.0,\n temperature_max: 2.0,\n default_temperature: 0.0,\n max_output_tokens: Some(32768),\n token_parameter: AITokenParameter::MaxTokens,\n thinking_strategy: AIThinkingStrategy::PromptOnly,\n },\n response_profile: OPENAI_RESPONSE_PROFILE,\n endpoint_variants: EMPTY_ENDPOINTS,\n api_key_env_hint: \"\",\n docs_url: Some(\"https://github.com/ollama/ollama/blob/main/docs/api.md\"),\n },\n];\n\npub fn provider_registry() -> &'static [AIProviderDescriptor] {\n PROVIDER_REGISTRY\n}\n\npub fn provider_descriptor(id: AIProviderPresetId) -> Option<&'static AIProviderDescriptor> {\n PROVIDER_REGISTRY\n .iter()\n .find(|descriptor| descriptor.id == id)\n}\n\n#[cfg(test)]\nmod tests {\n use super::*;\n\n #[test]\n fn registry_has_one_descriptor_for_each_supported_preset() {\n let expected = [\n AIProviderPresetId::DeepSeek,\n AIProviderPresetId::Kimi,\n AIProviderPresetId::QwenDashScope,\n AIProviderPresetId::ZhipuGlm,\n AIProviderPresetId::DoubaoArk,\n AIProviderPresetId::Minimax,\n AIProviderPresetId::Hunyuan,\n AIProviderPresetId::Siliconflow,\n AIProviderPresetId::BaiduQianfan,\n AIProviderPresetId::Baichuan,\n AIProviderPresetId::StepFun,\n AIProviderPresetId::Yi,\n AIProviderPresetId::CustomOpenAICompatible,\n AIProviderPresetId::Ollama,\n ];\n assert_eq!(provider_registry().len(), expected.len());\n for id in expected {\n assert_eq!(\n provider_registry()\n .iter()\n .filter(|item| item.id == id)\n .count(),\n 1\n );\n }\n }\n\n #[test]\n fn registry_snapshots_provider_specific_protocol_parameters() {\n let deepseek = provider_descriptor(AIProviderPresetId::DeepSeek).expect(\"DeepSeek\");\n assert_eq!(deepseek.default_base_url, \"https://api.deepseek.com\");\n assert!(deepseek.capabilities.supports_response_format_json_object);\n assert_eq!(\n deepseek.parameter_profile.thinking_strategy,\n AIThinkingStrategy::DeepSeekThinkingObject\n );\n\n let qwen = provider_descriptor(AIProviderPresetId::QwenDashScope).expect(\"Qwen\");\n assert!(qwen.capabilities.supports_response_format_json_object);\n assert_eq!(\n qwen.parameter_profile.thinking_strategy,\n AIThinkingStrategy::EnableThinkingBoolean\n );\n\n let minimax = provider_descriptor(AIProviderPresetId::Minimax).expect(\"MiniMax\");\n assert_eq!(\n minimax.parameter_profile.token_parameter,\n AITokenParameter::MaxCompletionTokens\n );\n assert_eq!(\n minimax.parameter_profile.thinking_strategy,\n AIThinkingStrategy::MiniMaxReasoningSplit\n );\n\n let qianfan = provider_descriptor(AIProviderPresetId::BaiduQianfan).expect(\"Qianfan\");\n assert_eq!(qianfan.auth_kind, AIAuthKind::BearerApiKey);\n assert_eq!(qianfan.default_base_url, \"https://qianfan.baidubce.com/v2\");\n }\n}\n"}, "f64cf0097f514b7395356b1f3df7c6af1020a12d": {"t": 1785757164.0500057, "n": 23417, "h": "1f108b13a19604d0", "c": "use serde::{Deserialize, Serialize};\n\nuse super::schema::{AIProviderKind, AIProviderPresetId};\n\n#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]\n#[serde(rename_all = \"snake_case\")]\npub enum AIAuthKind {\n None,\n BearerApiKey,\n ApiKeyHeader,\n QianfanAkSk,\n}\n\n#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]\n#[serde(rename_all = \"snake_case\")]\npub enum AITokenParameter {\n MaxTokens,\n MaxCompletionTokens,\n}\n\n#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]\n#[serde(rename_all = \"snake_case\")]\npub enum AIThinkingStrategy {\n None,\n DeepSeekThinkingObject,\n GenericThinkingObject,\n EnableThinkingBoolean,\n ReasoningEffort,\n MiniMaxReasoningSplit,\n PromptOnly,\n}\n\n#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]\n#[serde(rename_all = \"snake_case\")]\npub enum ResponseFieldPath {\n ChoicesMessageContent,\n ChoicesMessageOutputText,\n ChoicesMessageText,\n ChoicesMessageReasoningContent,\n ChoicesMessageReasoningDetails,\n RootOutputText,\n RootContent,\n}\n\n#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]\n#[serde(rename_all = \"camelCase\")]\npub struct AIProviderCapabilities {\n pub supports_model_discovery: bool,\n pub supports_response_format_json_object: bool,\n pub supports_json_schema: bool,\n pub supports_thinking: bool,\n pub supports_thinking_toggle: bool,\n pub supports_reasoning_effort: bool,\n pub supports_usage: bool,\n pub supports_streaming: bool,\n}\n\n#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]\n#[serde(rename_all = \"camelCase\")]\npub struct AIParameterProfile {\n pub temperature_min: f64,\n pub temperature_max: f64,\n pub default_temperature: f64,\n pub max_output_tokens: Option<u32>,\n pub token_parameter: AITokenParameter,\n pub thinking_strategy: AIThinkingStrategy,\n}\n\n#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]\n#[serde(rename_all = \"camelCase\")]\npub struct AIResponseProfile {\n pub content_paths: &'static [ResponseFieldPath],\n pub reasoning_paths: &'static [ResponseFieldPath],\n pub finish_reason_paths: &'static [ResponseFieldPath],\n pub usage_paths: &'static [ResponseFieldPath],\n pub strip_think_tags: bool,\n}\n\n#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]\n#[serde(rename_all = \"camelCase\")]\npub struct AIEndpointVariant {\n pub id: &'static str,\n pub label: &'static str,\n pub base_url: &'static str,\n}\n\n#[derive(Debug, Clone, Copy, PartialEq, Serialize)]\n#[serde(rename_all = \"camelCase\")]\npub struct AIProviderDescriptor {\n pub id: AIProviderPresetId,\n pub label: &'static str,\n pub provider_kind: AIProviderKind,\n pub default_base_url: &'static str,\n pub default_chat_path: &'static str,\n pub models_path: Option<&'static str>,\n pub default_model: &'static str,\n pub suggested_models: &'static [&'static str],\n pub auth_kind: AIAuthKind,\n pub capabilities: AIProviderCapabilities,\n pub parameter_profile: AIParameterProfile,\n pub response_profile: AIResponseProfile,\n pub endpoint_variants: &'static [AIEndpointVariant],\n pub api_key_env_hint: &'static str,\n pub docs_url: Option<&'static str>,\n}\n\nconst OPENAI_CONTENT_PATHS: &[ResponseFieldPath] = &[\n ResponseFieldPath::ChoicesMessageContent,\n ResponseFieldPath::ChoicesMessageOutputText,\n ResponseFieldPath::ChoicesMessageText,\n ResponseFieldPath::RootOutputText,\n ResponseFieldPath::RootContent,\n];\nconst OPENAI_REASONING_PATHS: &[ResponseFieldPath] = &[\n ResponseFieldPath::ChoicesMessageReasoningContent,\n ResponseFieldPath::ChoicesMessageReasoningDetails,\n];\nconst OPENAI_FINISH_PATHS: &[ResponseFieldPath] = &[];\nconst OPENAI_USAGE_PATHS: &[ResponseFieldPath] = &[];\nconst EMPTY_ENDPOINTS: &[AIEndpointVariant] = &[];\nconst DEEPSEEK_MODELS: &[&str] = &[\"deepseek-v4-flash\", \"deepseek-v4-pro\"];\nconst KIMI_MODELS: &[&str] = &[\"kimi-k2.6\", \"kimi-k2-thinking\", \"moonshot-v1-128k\"];\nconst QWEN_MODELS: &[&str] = &[\"qwen-plus\", \"qwen3.6-plus\", \"qwen3.5-plus\", \"qwen3-vl-plus\"];\nconst GLM_MODELS: &[&str] = &[\"glm-5.2\", \"glm-5\", \"glm-4.7\", \"glm-4.5\"];\nconst MINIMAX_MODELS: &[&str] = &[\"MiniMax-M2.5\", \"MiniMax-M2.1\", \"MiniMax-M2\"];\nconst DOUBAO_MODELS: &[&str] = &[\"ep-2024-...\", \"doubao-seed-1-6-250615\"];\nconst HUNYUAN_MODELS: &[&str] = &[\"hunyuan-turbos\", \"hunyuan-pro\"];\nconst STEPFUN_MODELS: &[&str] = &[\"step-3.5-flash\", \"step-3.5-turbo\"];\nconst OLLAMA_MODELS: &[&str] = &[\"qwen3:8b\", \"llama3.2:3b\"];\n\nconst DASHSCOPE_ENDPOINTS: &[AIEndpointVariant] = &[\n AIEndpointVariant {\n id: \"cn-beijing\",\n label: \"中国内地(北京)\",\n base_url: \"https://dashscope.aliyuncs.com/compatible-mode/v1\",\n },\n AIEndpointVariant {\n id: \"us-virginia\",\n label: \"美国(弗吉尼亚)\",\n base_url: \"https://dashscope-us.aliyuncs.com/compatible-mode/v1\",\n },\n AIEndpointVariant {\n id: \"singapore-workspace\",\n label: \"新加坡(Workspace Endpoint)\",\n base_url: \"https://{WorkspaceId}.ap-southeast-1.maas.aliyuncs.com/compatible-mode/v1\",\n },\n AIEndpointVariant {\n id: \"japan-workspace\",\n label: \"日本(Workspace Endpoint)\",\n base_url: \"https://{WorkspaceId}.ap-northeast-1.maas.aliyuncs.com/compatible-mode/v1\",\n },\n];\n\nconst OPENAI_RESPONSE_PROFILE: AIResponseProfile = AIResponseProfile {\n content_paths: OPENAI_CONTENT_PATHS,\n reasoning_paths: OPENAI_REASONING_PATHS,\n finish_reason_paths: OPENAI_FINISH_PATHS,\n usage_paths: OPENAI_USAGE_PATHS,\n strip_think_tags: false,\n};\nconst MINIMAX_RESPONSE_PROFILE: AIResponseProfile = AIResponseProfile {\n content_paths: OPENAI_CONTENT_PATHS,\n reasoning_paths: OPENAI_REASONING_PATHS,\n finish_reason_paths: OPENAI_FINISH_PATHS,\n usage_paths: OPENAI_USAGE_PATHS,\n strip_think_tags: true,\n};\n\nconst PROMPT_ONLY: AIProviderCapabilities = AIProviderCapabilities {\n supports_model_discovery: true,\n supports_response_format_json_object: false,\n supports_json_schema: false,\n supports_thinking: false,\n supports_thinking_toggle: false,\n supports_reasoning_effort: false,\n supports_usage: true,\n supports_streaming: true,\n};\n\nconst DEFAULT_OPENAI_PARAMETERS: AIParameterProfile = AIParameterProfile {\n temperature_min: 0.0,\n temperature_max: 2.0,\n default_temperature: 0.0,\n max_output_tokens: None,\n token_parameter: AITokenParameter::MaxTokens,\n thinking_strategy: AIThinkingStrategy::PromptOnly,\n};\nconst QWEN_CAPABILITIES: AIProviderCapabilities = AIProviderCapabilities {\n supports_model_discovery: true,\n supports_response_format_json_object: true,\n supports_json_schema: false,\n supports_thinking: true,\n supports_thinking_toggle: true,\n supports_reasoning_effort: false,\n supports_usage: true,\n supports_streaming: true,\n};\n\npub static PROVIDER_REGISTRY: &[AIProviderDescriptor] = &[\n AIProviderDescriptor {\n id: AIProviderPresetId::DeepSeek,\n label: \"DeepSeek\",\n provider_kind: AIProviderKind::OpenAICompatible,\n default_base_url: \"https://api.deepseek.com\",\n default_chat_path: \"/chat/completions\",\n models_path: Some(\"/models\"),\n default_model: \"deepseek-v4-flash\",\n suggested_models: DEEPSEEK_MODELS,\n auth_kind: AIAuthKind::BearerApiKey,\n capabilities: AIProviderCapabilities {\n supports_model_discovery: true,\n supports_response_format_json_object: true,\n supports_json_schema: false,\n supports_thinking: true,\n supports_thinking_toggle: true,\n supports_reasoning_effort: true,\n supports_usage: true,\n supports_streaming: true,\n },\n parameter_profile: AIParameterProfile {\n temperature_min: 0.0,\n temperature_max: 2.0,\n default_temperature: 0.0,\n max_output_tokens: Some(8192),\n token_parameter: AITokenParameter::MaxTokens,\n thinking_strategy: AIThinkingStrategy::DeepSeekThinkingObject,\n },\n response_profile: OPENAI_RESPONSE_PROFILE,\n endpoint_variants: EMPTY_ENDPOINTS,\n api_key_env_hint: \"DEEPSEEK_API_KEY\",\n docs_url: Some(\"https://api-docs.deepseek.com/\"),\n },\n AIProviderDescriptor {\n id: AIProviderPresetId::Kimi,\n label: \"Kimi / Moonshot\",\n provider_kind: AIProviderKind::OpenAICompatible,\n default_base_url: \"https://api.moonshot.ai/v1\",\n default_chat_path: \"/chat/completions\",\n models_path: Some(\"/models\"),\n default_model: \"kimi-k2.6\",\n suggested_models: KIMI_MODELS,\n auth_kind: AIAuthKind::BearerApiKey,\n capabilities: AIProviderCapabilities {\n supports_model_discovery: true,\n supports_response_format_json_object: true,\n supports_json_schema: false,\n supports_thinking: true,\n supports_thinking_toggle: true,\n supports_reasoning_effort: false,\n supports_usage: true,\n supports_streaming: true,\n },\n parameter_profile: AIParameterProfile {\n temperature_min: 0.0,\n temperature_max: 1.0,\n default_temperature: 0.0,\n max_output_tokens: Some(32768),\n token_parameter: AITokenParameter::MaxTokens,\n thinking_strategy: AIThinkingStrategy::GenericThinkingObject,\n },\n response_profile: OPENAI_RESPONSE_PROFILE,\n endpoint_variants: EMPTY_ENDPOINTS,\n api_key_env_hint: \"MOONSHOT_API_KEY\",\n docs_url: Some(\"https://platform.kimi.ai/docs\"),\n },\n AIProviderDescriptor {\n id: AIProviderPresetId::QwenDashScope,\n label: \"Qwen / 阿里云百炼\",\n provider_kind: AIProviderKind::OpenAICompatible,\n default_base_url: \"https://dashscope.aliyuncs.com/compatible-mode/v1\",\n default_chat_path: \"/chat/completions\",\n models_path: Some(\"/models\"),\n default_model: \"qwen-plus\",\n suggested_models: QWEN_MODELS,\n auth_kind: AIAuthKind::BearerApiKey,\n capabilities: QWEN_CAPABILITIES,\n parameter_profile: AIParameterProfile {\n temperature_min: 0.0,\n temperature_max: 2.0,\n default_temperature: 0.0,\n max_output_tokens: Some(32768),\n token_parameter: AITokenParameter::MaxTokens,\n thinking_strategy: AIThinkingStrategy::EnableThinkingBoolean,\n },\n response_profile: OPENAI_RESPONSE_PROFILE,\n endpoint_variants: DASHSCOPE_ENDPOINTS,\n api_key_env_hint: \"DASHSCOPE_API_KEY\",\n docs_url: Some(\"https://help.aliyun.com/model-studio/\"),\n },\n AIProviderDescriptor {\n id: AIProviderPresetId::ZhipuGlm,\n label: \"智谱 GLM\",\n provider_kind: AIProviderKind::OpenAICompatible,\n default_base_url: \"https://open.bigmodel.cn/api/paas/v4\",\n default_chat_path: \"/chat/completions\",\n models_path: None,\n default_model: \"glm-5.2\",\n suggested_models: GLM_MODELS,\n auth_kind: AIAuthKind::BearerApiKey,\n capabilities: AIProviderCapabilities {\n supports_model_discovery: false,\n supports_response_format_json_object: true,\n supports_json_schema: false,\n supports_thinking: true,\n supports_thinking_toggle: true,\n supports_reasoning_effort: true,\n supports_usage: true,\n supports_streaming: true,\n },\n parameter_profile: AIParameterProfile {\n temperature_min: 0.0,\n temperature_max: 2.0,\n default_temperature: 0.0,\n max_output_tokens: Some(32768),\n token_parameter: AITokenParameter::MaxTokens,\n thinking_strategy: AIThinkingStrategy::GenericThinkingObject,\n },\n response_profile: OPENAI_RESPONSE_PROFILE,\n endpoint_variants: EMPTY_ENDPOINTS,\n api_key_env_hint: \"ZHIPUAI_API_KEY\",\n docs_url: Some(\"https://docs.bigmodel.cn/\"),\n },\n AIProviderDescriptor {\n id: AIProviderPresetId::DoubaoArk,\n label: \"豆包 / 火山方舟\",\n provider_kind: AIProviderKind::OpenAICompatible,\n default_base_url: \"https://ark.cn-beijing.volces.com/api/v3\",\n default_chat_path: \"/chat/completions\",\n models_path: Some(\"/models\"),\n default_model: \"\",\n suggested_models: DOUBAO_MODELS,\n auth_kind: AIAuthKind::BearerApiKey,\n capabilities: PROMPT_ONLY,\n parameter_profile: AIParameterProfile {\n temperature_min: 0.0,\n temperature_max: 2.0,\n default_temperature: 0.0,\n max_output_tokens: Some(32768),\n token_parameter: AITokenParameter::MaxTokens,\n thinking_strategy: AIThinkingStrategy::PromptOnly,\n },\n response_profile: OPENAI_RESPONSE_PROFILE,\n endpoint_variants: EMPTY_ENDPOINTS,\n api_key_env_hint: \"ARK_API_KEY\",\n docs_url: Some(\"https://www.volcengine.com/docs/82379\"),\n },\n AIProviderDescriptor {\n id: AIProviderPresetId::Minimax,\n label: \"MiniMax\",\n provider_kind: AIProviderKind::OpenAICompatible,\n default_base_url: \"https://api.minimaxi.com/v1\",\n default_chat_path: \"/chat/completions\",\n models_path: Some(\"/models\"),\n default_model: \"MiniMax-M2.5\",\n suggested_models: MINIMAX_MODELS,\n auth_kind: AIAuthKind::BearerApiKey,\n capabilities: AIProviderCapabilities {\n supports_model_discovery: true,\n supports_response_format_json_object: true,\n supports_json_schema: false,\n supports_thinking: true,\n supports_thinking_toggle: true,\n supports_reasoning_effort: false,\n supports_usage: true,\n supports_streaming: true,\n },\n parameter_profile: AIParameterProfile {\n temperature_min: 0.0,\n temperature_max: 1.0,\n default_temperature: 0.0,\n max_output_tokens: Some(32768),\n token_parameter: AITokenParameter::MaxCompletionTokens,\n thinking_strategy: AIThinkingStrategy::MiniMaxReasoningSplit,\n },\n response_profile: MINIMAX_RESPONSE_PROFILE,\n endpoint_variants: EMPTY_ENDPOINTS,\n api_key_env_hint: \"MINIMAX_API_KEY\",\n docs_url: Some(\"https://platform.minimaxi.com/document\"),\n },\n AIProviderDescriptor {\n id: AIProviderPresetId::Hunyuan,\n label: \"腾讯混元\",\n provider_kind: AIProviderKind::OpenAICompatible,\n default_base_url: \"https://api.hunyuan.cloud.tencent.com/v1\",\n default_chat_path: \"/chat/completions\",\n models_path: Some(\"/models\"),\n default_model: \"hunyuan-turbos\",\n suggested_models: HUNYUAN_MODELS,\n auth_kind: AIAuthKind::BearerApiKey,\n capabilities: PROMPT_ONLY,\n parameter_profile: DEFAULT_OPENAI_PARAMETERS,\n response_profile: OPENAI_RESPONSE_PROFILE,\n endpoint_variants: EMPTY_ENDPOINTS,\n api_key_env_hint: \"HUNYUAN_API_KEY\",\n docs_url: Some(\"https://cloud.tencent.com/document/product/1729\"),\n },\n AIProviderDescriptor {\n id: AIProviderPresetId::Siliconflow,\n label: \"SiliconFlow / 硅基流动\",\n provider_kind: AIProviderKind::OpenAICompatible,\n default_base_url: \"https://api.siliconflow.cn/v1\",\n default_chat_path: \"/chat/completions\",\n models_path: Some(\"/models\"),\n default_model: \"\",\n suggested_models: &[],\n auth_kind: AIAuthKind::BearerApiKey,\n capabilities: PROMPT_ONLY,\n parameter_profile: DEFAULT_OPENAI_PARAMETERS,\n response_profile: OPENAI_RESPONSE_PROFILE,\n endpoint_variants: EMPTY_ENDPOINTS,\n api_key_env_hint: \"SILICONFLOW_API_KEY\",\n docs_url: Some(\"https://docs.siliconflow.cn/\"),\n },\n AIProviderDescriptor {\n id: AIProviderPresetId::BaiduQianfan,\n label: \"百度千帆 / 文心\",\n provider_kind: AIProviderKind::OpenAICompatible,\n default_base_url: \"https://qianfan.baidubce.com/v2\",\n default_chat_path: \"/chat/completions\",\n models_path: None,\n default_model: \"ernie-4.5-turbo-32k\",\n suggested_models: &[\"ernie-4.5-turbo-32k\", \"ernie-speed-128k\"],\n auth_kind: AIAuthKind::BearerApiKey,\n capabilities: PROMPT_ONLY,\n parameter_profile: DEFAULT_OPENAI_PARAMETERS,\n response_profile: OPENAI_RESPONSE_PROFILE,\n endpoint_variants: EMPTY_ENDPOINTS,\n api_key_env_hint: \"QIANFAN_API_KEY\",\n docs_url: Some(\"https://cloud.baidu.com/doc/WENXINWORKSHOP/\"),\n },\n AIProviderDescriptor {\n id: AIProviderPresetId::Baichuan,\n label: \"百川智能(兼容入口)\",\n provider_kind: AIProviderKind::OpenAICompatible,\n default_base_url: \"\",\n default_chat_path: \"/chat/completions\",\n models_path: None,\n default_model: \"\",\n suggested_models: &[\"Baichuan4-Turbo\"],\n auth_kind: AIAuthKind::BearerApiKey,\n capabilities: PROMPT_ONLY,\n parameter_profile: DEFAULT_OPENAI_PARAMETERS,\n response_profile: OPENAI_RESPONSE_PROFILE,\n endpoint_variants: EMPTY_ENDPOINTS,\n api_key_env_hint: \"BAICHUAN_API_KEY\",\n docs_url: Some(\"https://platform.baichuan-ai.com/docs/api\"),\n },\n AIProviderDescriptor {\n id: AIProviderPresetId::StepFun,\n label: \"阶跃星辰 / StepFun\",\n provider_kind: AIProviderKind::OpenAICompatible,\n default_base_url: \"https://api.stepfun.com/v1\",\n default_chat_path: \"/chat/completions\",\n models_path: Some(\"/models\"),\n default_model: \"step-3.5-flash\",\n suggested_models: STEPFUN_MODELS,\n auth_kind: AIAuthKind::BearerApiKey,\n capabilities: PROMPT_ONLY,\n parameter_profile: DEFAULT_OPENAI_PARAMETERS,\n response_profile: OPENAI_RESPONSE_PROFILE,\n endpoint_variants: EMPTY_ENDPOINTS,\n api_key_env_hint: \"STEPFUN_API_KEY\",\n docs_url: Some(\"https://platform.stepfun.com/docs\"),\n },\n AIProviderDescriptor {\n id: AIProviderPresetId::Yi,\n label: \"零一万物 / Yi(兼容平台)\",\n provider_kind: AIProviderKind::OpenAICompatible,\n default_base_url: \"\",\n default_chat_path: \"/chat/completions\",\n models_path: None,\n default_model: \"\",\n suggested_models: &[\"yi-large\"],\n auth_kind: AIAuthKind::BearerApiKey,\n capabilities: PROMPT_ONLY,\n parameter_profile: DEFAULT_OPENAI_PARAMETERS,\n response_profile: OPENAI_RESPONSE_PROFILE,\n endpoint_variants: EMPTY_ENDPOINTS,\n api_key_env_hint: \"YI_API_KEY\",\n docs_url: Some(\"https://platform.lingyiwanwu.com/\"),\n },\n AIProviderDescriptor {\n id: AIProviderPresetId::CustomOpenAICompatible,\n label: \"自定义 OpenAI-compatible\",\n provider_kind: AIProviderKind::OpenAICompatible,\n default_base_url: \"\",\n default_chat_path: \"/chat/completions\",\n models_path: Some(\"/models\"),\n default_model: \"\",\n suggested_models: &[],\n auth_kind: AIAuthKind::BearerApiKey,\n capabilities: PROMPT_ONLY,\n parameter_profile: DEFAULT_OPENAI_PARAMETERS,\n response_profile: OPENAI_RESPONSE_PROFILE,\n endpoint_variants: EMPTY_ENDPOINTS,\n api_key_env_hint: \"OPENAI_COMPATIBLE_API_KEY\",\n docs_url: None,\n },\n AIProviderDescriptor {\n id: AIProviderPresetId::Ollama,\n label: \"Ollama 本地模型\",\n provider_kind: AIProviderKind::Ollama,\n default_base_url: \"http://localhost:11434\",\n default_chat_path: \"/api/chat\",\n models_path: Some(\"/api/tags\"),\n default_model: \"qwen3:8b\",\n suggested_models: OLLAMA_MODELS,\n auth_kind: AIAuthKind::None,\n capabilities: AIProviderCapabilities {\n supports_model_discovery: true,\n supports_response_format_json_object: false,\n supports_json_schema: false,\n supports_thinking: true,\n supports_thinking_toggle: true,\n supports_reasoning_effort: false,\n supports_usage: true,\n supports_streaming: true,\n },\n parameter_profile: AIParameterProfile {\n temperature_min: 0.0,\n temperature_max: 2.0,\n default_temperature: 0.0,\n max_output_tokens: Some(32768),\n token_parameter: AITokenParameter::MaxTokens,\n thinking_strategy: AIThinkingStrategy::PromptOnly,\n },\n response_profile: OPENAI_RESPONSE_PROFILE,\n endpoint_variants: EMPTY_ENDPOINTS,\n api_key_env_hint: \"\",\n docs_url: Some(\"https://github.com/ollama/ollama/blob/main/docs/api.md\"),\n },\n];\n\npub fn provider_registry() -> &'static [AIProviderDescriptor] {\n PROVIDER_REGISTRY\n}\n\npub fn provider_descriptor(id: AIProviderPresetId) -> Option<&'static AIProviderDescriptor> {\n PROVIDER_REGISTRY\n .iter()\n .find(|descriptor| descriptor.id == id)\n}\n\n#[cfg(test)]\nmod tests {\n use super::*;\n\n #[test]\n fn registry_has_one_descriptor_for_each_supported_preset() {\n let expected = [\n AIProviderPresetId::DeepSeek,\n AIProviderPresetId::Kimi,\n AIProviderPresetId::QwenDashScope,\n AIProviderPresetId::ZhipuGlm,\n AIProviderPresetId::DoubaoArk,\n AIProviderPresetId::Minimax,\n AIProviderPresetId::Hunyuan,\n AIProviderPresetId::Siliconflow,\n AIProviderPresetId::BaiduQianfan,\n AIProviderPresetId::Baichuan,\n AIProviderPresetId::StepFun,\n AIProviderPresetId::Yi,\n AIProviderPresetId::CustomOpenAICompatible,\n AIProviderPresetId::Ollama,\n ];\n assert_eq!(provider_registry().len(), expected.len());\n for id in expected {\n assert_eq!(\n provider_registry()\n .iter()\n .filter(|item| item.id == id)\n .count(),\n 1\n );\n }\n }\n\n #[test]\n fn registry_snapshots_provider_specific_protocol_parameters() {\n let deepseek = provider_descriptor(AIProviderPresetId::DeepSeek).expect(\"DeepSeek\");\n assert_eq!(deepseek.default_base_url, \"https://api.deepseek.com\");\n assert!(deepseek.capabilities.supports_response_format_json_object);\n assert_eq!(\n deepseek.parameter_profile.thinking_strategy,\n AIThinkingStrategy::DeepSeekThinkingObject\n );\n\n let qwen = provider_descriptor(AIProviderPresetId::QwenDashScope).expect(\"Qwen\");\n assert!(qwen.capabilities.supports_response_format_json_object);\n assert_eq!(\n qwen.parameter_profile.thinking_strategy,\n AIThinkingStrategy::EnableThinkingBoolean\n );\n\n let minimax = provider_descriptor(AIProviderPresetId::Minimax).expect(\"MiniMax\");\n assert_eq!(\n minimax.parameter_profile.token_parameter,\n AITokenParameter::MaxCompletionTokens\n );\n assert_eq!(\n minimax.parameter_profile.thinking_strategy,\n AIThinkingStrategy::MiniMaxReasoningSplit\n );\n\n let qianfan = provider_descriptor(AIProviderPresetId::BaiduQianfan).expect(\"Qianfan\");\n assert_eq!(qianfan.auth_kind, AIAuthKind::BearerApiKey);\n assert_eq!(qianfan.default_base_url, \"https://qianfan.baidubce.com/v2\");\n }\n}\n"}, "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"}}}}