MaiBot 1.3.8__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (716) hide show
  1. maibot-1.3.8.dist-info/METADATA +245 -0
  2. maibot-1.3.8.dist-info/RECORD +716 -0
  3. maibot-1.3.8.dist-info/WHEEL +5 -0
  4. maibot-1.3.8.dist-info/entry_points.txt +2 -0
  5. maibot-1.3.8.dist-info/licenses/LICENSE +674 -0
  6. maibot-1.3.8.dist-info/top_level.txt +1 -0
  7. src/A_memorix/LICENSE +661 -0
  8. src/A_memorix/LICENSE-MAIBOT-GPL.md +22 -0
  9. src/A_memorix/__init__.py +5 -0
  10. src/A_memorix/config_schema.json +1640 -0
  11. src/A_memorix/core/__init__.py +83 -0
  12. src/A_memorix/core/embedding/__init__.py +17 -0
  13. src/A_memorix/core/embedding/api_adapter.py +650 -0
  14. src/A_memorix/core/embedding/manager.py +506 -0
  15. src/A_memorix/core/embedding/presets.py +68 -0
  16. src/A_memorix/core/image/__init__.py +6 -0
  17. src/A_memorix/core/image/asset_store.py +141 -0
  18. src/A_memorix/core/image/component_paths.py +36 -0
  19. src/A_memorix/core/image/embedder.py +129 -0
  20. src/A_memorix/core/image/fingerprints.py +36 -0
  21. src/A_memorix/core/image/runtime.py +900 -0
  22. src/A_memorix/core/retrieval/__init__.py +66 -0
  23. src/A_memorix/core/retrieval/dual_path.py +3272 -0
  24. src/A_memorix/core/retrieval/graph_relation_recall.py +285 -0
  25. src/A_memorix/core/retrieval/pagerank.py +471 -0
  26. src/A_memorix/core/retrieval/posterior_graph.py +787 -0
  27. src/A_memorix/core/retrieval/score_calibration.py +180 -0
  28. src/A_memorix/core/retrieval/sparse_bm25.py +717 -0
  29. src/A_memorix/core/retrieval/threshold.py +404 -0
  30. src/A_memorix/core/runtime/__init__.py +26 -0
  31. src/A_memorix/core/runtime/admin_contracts.py +293 -0
  32. src/A_memorix/core/runtime/kernel_compat.py +269 -0
  33. src/A_memorix/core/runtime/lifecycle_orchestrator.py +368 -0
  34. src/A_memorix/core/runtime/models.py +27 -0
  35. src/A_memorix/core/runtime/runtime_facade.py +141 -0
  36. src/A_memorix/core/runtime/runtime_writer_lock.py +105 -0
  37. src/A_memorix/core/runtime/sdk_memory_kernel.py +1907 -0
  38. src/A_memorix/core/runtime/search_runtime_initializer.py +290 -0
  39. src/A_memorix/core/runtime/services/__init__.py +63 -0
  40. src/A_memorix/core/runtime/services/background_task_service.py +477 -0
  41. src/A_memorix/core/runtime/services/base.py +28 -0
  42. src/A_memorix/core/runtime/services/bundle_admin_service.py +2200 -0
  43. src/A_memorix/core/runtime/services/chat_filter_service.py +88 -0
  44. src/A_memorix/core/runtime/services/correction_admin_service.py +1274 -0
  45. src/A_memorix/core/runtime/services/delete_admin_service.py +2018 -0
  46. src/A_memorix/core/runtime/services/dual_vector_migration_service.py +109 -0
  47. src/A_memorix/core/runtime/services/dual_vector_state_service.py +503 -0
  48. src/A_memorix/core/runtime/services/embedding_state_service.py +515 -0
  49. src/A_memorix/core/runtime/services/episode_admin_service.py +104 -0
  50. src/A_memorix/core/runtime/services/fact_admin_service.py +199 -0
  51. src/A_memorix/core/runtime/services/feedback_correction_service.py +1523 -0
  52. src/A_memorix/core/runtime/services/graph_admin_service.py +1521 -0
  53. src/A_memorix/core/runtime/services/image_service.py +158 -0
  54. src/A_memorix/core/runtime/services/import_tuning_admin_service.py +158 -0
  55. src/A_memorix/core/runtime/services/ingest_service.py +787 -0
  56. src/A_memorix/core/runtime/services/memory_maintenance_service.py +404 -0
  57. src/A_memorix/core/runtime/services/memory_search_service.py +373 -0
  58. src/A_memorix/core/runtime/services/profile_admin_service.py +646 -0
  59. src/A_memorix/core/runtime/services/request_dedup_service.py +34 -0
  60. src/A_memorix/core/runtime/services/runtime_config_service.py +94 -0
  61. src/A_memorix/core/runtime/services/runtime_dependency_service.py +105 -0
  62. src/A_memorix/core/runtime/services/runtime_lifecycle_service.py +409 -0
  63. src/A_memorix/core/runtime/services/search_hit_processing_service.py +891 -0
  64. src/A_memorix/core/runtime/services/source_admin_service.py +52 -0
  65. src/A_memorix/core/runtime/services/stats_service.py +35 -0
  66. src/A_memorix/core/runtime/services/summary_service.py +50 -0
  67. src/A_memorix/core/runtime/services/v5_admin_service.py +326 -0
  68. src/A_memorix/core/runtime/services/vector_delete_service.py +30 -0
  69. src/A_memorix/core/runtime/services/vector_rebuild_checkpoint.py +65 -0
  70. src/A_memorix/core/runtime/services/vector_recovery_service.py +460 -0
  71. src/A_memorix/core/runtime/services/vector_runtime_service.py +1417 -0
  72. src/A_memorix/core/runtime/services/vector_space_service.py +251 -0
  73. src/A_memorix/core/storage/__init__.py +62 -0
  74. src/A_memorix/core/storage/format_migration.py +676 -0
  75. src/A_memorix/core/storage/graph_store.py +1733 -0
  76. src/A_memorix/core/storage/knowledge_types.py +183 -0
  77. src/A_memorix/core/storage/metadata_cleanup.py +913 -0
  78. src/A_memorix/core/storage/metadata_episode.py +1659 -0
  79. src/A_memorix/core/storage/metadata_fact.py +1410 -0
  80. src/A_memorix/core/storage/metadata_feedback.py +745 -0
  81. src/A_memorix/core/storage/metadata_fts.py +937 -0
  82. src/A_memorix/core/storage/metadata_image.py +1076 -0
  83. src/A_memorix/core/storage/metadata_profile.py +782 -0
  84. src/A_memorix/core/storage/metadata_schema.py +2503 -0
  85. src/A_memorix/core/storage/metadata_store.py +4565 -0
  86. src/A_memorix/core/storage/sqlite_connection.py +176 -0
  87. src/A_memorix/core/storage/tokenizer_runtime.py +9 -0
  88. src/A_memorix/core/storage/transaction.py +42 -0
  89. src/A_memorix/core/storage/type_detection.py +137 -0
  90. src/A_memorix/core/storage/value_coercion.py +10 -0
  91. src/A_memorix/core/storage/vector_store.py +2851 -0
  92. src/A_memorix/core/strategies/__init__.py +0 -0
  93. src/A_memorix/core/strategies/base.py +92 -0
  94. src/A_memorix/core/strategies/chat_log.py +80 -0
  95. src/A_memorix/core/strategies/factual.py +104 -0
  96. src/A_memorix/core/strategies/narrative.py +148 -0
  97. src/A_memorix/core/strategies/quote.py +47 -0
  98. src/A_memorix/core/utils/__init__.py +33 -0
  99. src/A_memorix/core/utils/aggregate_query_service.py +359 -0
  100. src/A_memorix/core/utils/episode_retrieval_service.py +222 -0
  101. src/A_memorix/core/utils/episode_segmentation_service.py +322 -0
  102. src/A_memorix/core/utils/episode_service.py +646 -0
  103. src/A_memorix/core/utils/feedback_policy.py +149 -0
  104. src/A_memorix/core/utils/hash.py +128 -0
  105. src/A_memorix/core/utils/import_payloads.py +222 -0
  106. src/A_memorix/core/utils/io.py +85 -0
  107. src/A_memorix/core/utils/matcher.py +175 -0
  108. src/A_memorix/core/utils/memory_lifecycle_policy.py +336 -0
  109. src/A_memorix/core/utils/metadata.py +11 -0
  110. src/A_memorix/core/utils/model_routing.py +239 -0
  111. src/A_memorix/core/utils/monitor.py +188 -0
  112. src/A_memorix/core/utils/path_fallback_service.py +182 -0
  113. src/A_memorix/core/utils/person_profile_service.py +1277 -0
  114. src/A_memorix/core/utils/plugin_id_policy.py +26 -0
  115. src/A_memorix/core/utils/profile_evidence.py +25 -0
  116. src/A_memorix/core/utils/profile_policy.py +45 -0
  117. src/A_memorix/core/utils/profile_text.py +204 -0
  118. src/A_memorix/core/utils/quantization.py +343 -0
  119. src/A_memorix/core/utils/relation_query.py +121 -0
  120. src/A_memorix/core/utils/relation_write_service.py +504 -0
  121. src/A_memorix/core/utils/retrieval_tuning_manager.py +2369 -0
  122. src/A_memorix/core/utils/runtime_payloads.py +132 -0
  123. src/A_memorix/core/utils/runtime_self_check.py +303 -0
  124. src/A_memorix/core/utils/search_execution_service.py +445 -0
  125. src/A_memorix/core/utils/search_postprocess.py +91 -0
  126. src/A_memorix/core/utils/summary_importer.py +991 -0
  127. src/A_memorix/core/utils/time_parser.py +156 -0
  128. src/A_memorix/core/utils/web_import_manager.py +4511 -0
  129. src/A_memorix/host_service.py +1018 -0
  130. src/A_memorix/paths.py +58 -0
  131. src/A_memorix/plugin.py +321 -0
  132. src/A_memorix/runtime_registry.py +30 -0
  133. src/A_memorix/scripts/_bootstrap.py +22 -0
  134. src/A_memorix/scripts/audit_vector_consistency.py +205 -0
  135. src/A_memorix/scripts/backfill_relation_vectors.py +265 -0
  136. src/A_memorix/scripts/backfill_temporal_metadata.py +67 -0
  137. src/A_memorix/scripts/convert_lpmm.py +537 -0
  138. src/A_memorix/scripts/import_lpmm_json.py +189 -0
  139. src/A_memorix/scripts/migrate_chat_history.py +101 -0
  140. src/A_memorix/scripts/migrate_maibot_memory.py +1955 -0
  141. src/A_memorix/scripts/migrate_person_memory_points.py +113 -0
  142. src/A_memorix/scripts/process_knowledge.py +831 -0
  143. src/A_memorix/scripts/rebuild_episodes.py +207 -0
  144. src/A_memorix/scripts/release_vnext_migrate.py +875 -0
  145. src/A_memorix/scripts/runtime_self_check.py +144 -0
  146. src/__init__.py +14 -0
  147. src/_resources/EULA.md +135 -0
  148. src/_resources/PRIVACY.md +60 -0
  149. src/_resources/changelogs/changelog.md +918 -0
  150. src/_resources/locales/en-US/config.json +39 -0
  151. src/_resources/locales/en-US/core.json +8 -0
  152. src/_resources/locales/en-US/prompts.json +8 -0
  153. src/_resources/locales/en-US/startup.json +98 -0
  154. src/_resources/locales/ja-JP/config.json +39 -0
  155. src/_resources/locales/ja-JP/core.json +8 -0
  156. src/_resources/locales/ja-JP/prompts.json +8 -0
  157. src/_resources/locales/ja-JP/startup.json +98 -0
  158. src/_resources/locales/ko/config.json +39 -0
  159. src/_resources/locales/ko/core.json +8 -0
  160. src/_resources/locales/ko/prompts.json +8 -0
  161. src/_resources/locales/ko/startup.json +98 -0
  162. src/_resources/locales/zh-CN/config.json +39 -0
  163. src/_resources/locales/zh-CN/core.json +8 -0
  164. src/_resources/locales/zh-CN/prompts.json +8 -0
  165. src/_resources/locales/zh-CN/startup.json +98 -0
  166. src/_resources/prompts/en-US/.meta.toml +164 -0
  167. src/_resources/prompts/en-US/a_memorix_chat_summary.prompt +45 -0
  168. src/_resources/prompts/en-US/emoji_content_analysis.prompt +3 -0
  169. src/_resources/prompts/en-US/emoji_content_filtration.prompt +6 -0
  170. src/_resources/prompts/en-US/emoji_replace.prompt +12 -0
  171. src/_resources/prompts/en-US/emoji_selection.prompt +6 -0
  172. src/_resources/prompts/en-US/expression_evaluation.prompt +14 -0
  173. src/_resources/prompts/en-US/heuristic_memory_impression.prompt +15 -0
  174. src/_resources/prompts/en-US/image_description.prompt +1 -0
  175. src/_resources/prompts/en-US/jargon_compare_inference.prompt +14 -0
  176. src/_resources/prompts/en-US/jargon_inference_content_only.prompt +11 -0
  177. src/_resources/prompts/en-US/jargon_inference_with_context.prompt +19 -0
  178. src/_resources/prompts/en-US/learn_jargon.prompt +31 -0
  179. src/_resources/prompts/en-US/learn_style.prompt +28 -0
  180. src/_resources/prompts/en-US/maisaka_chat.prompt +28 -0
  181. src/_resources/prompts/en-US/maisaka_chat_focus.prompt +39 -0
  182. src/_resources/prompts/en-US/maisaka_chat_optimized.prompt +19 -0
  183. src/_resources/prompts/en-US/maisaka_replyer.prompt +10 -0
  184. src/_resources/prompts/en-US/memory_fuzzy_modify_plan.prompt +45 -0
  185. src/_resources/prompts/en-US/mid_term_memory_summary.prompt +10 -0
  186. src/_resources/prompts/en-US/reply_attachments.prompt +3 -0
  187. src/_resources/prompts/en-US/retro_private_replyer.prompt +13 -0
  188. src/_resources/prompts/en-US/retro_private_replyer_self.prompt +13 -0
  189. src/_resources/prompts/en-US/retro_replyer.prompt +17 -0
  190. src/_resources/prompts/en-US/retro_replyer_light.prompt +17 -0
  191. src/_resources/prompts/en-US/webui_ai_search.prompt +24 -0
  192. src/_resources/prompts/en-US/webui_ai_search_correction.prompt +1 -0
  193. src/_resources/prompts/en-US/webui_ai_search_evidence.prompt +3 -0
  194. src/_resources/prompts/en-US/webui_ai_search_final.prompt +1 -0
  195. src/_resources/prompts/ja-JP/.meta.toml +164 -0
  196. src/_resources/prompts/ja-JP/a_memorix_chat_summary.prompt +45 -0
  197. src/_resources/prompts/ja-JP/emoji_content_analysis.prompt +3 -0
  198. src/_resources/prompts/ja-JP/emoji_content_filtration.prompt +6 -0
  199. src/_resources/prompts/ja-JP/emoji_replace.prompt +12 -0
  200. src/_resources/prompts/ja-JP/emoji_selection.prompt +6 -0
  201. src/_resources/prompts/ja-JP/expression_evaluation.prompt +14 -0
  202. src/_resources/prompts/ja-JP/heuristic_memory_impression.prompt +15 -0
  203. src/_resources/prompts/ja-JP/image_description.prompt +1 -0
  204. src/_resources/prompts/ja-JP/jargon_compare_inference.prompt +14 -0
  205. src/_resources/prompts/ja-JP/jargon_inference_content_only.prompt +11 -0
  206. src/_resources/prompts/ja-JP/jargon_inference_with_context.prompt +19 -0
  207. src/_resources/prompts/ja-JP/learn_jargon.prompt +31 -0
  208. src/_resources/prompts/ja-JP/learn_style.prompt +28 -0
  209. src/_resources/prompts/ja-JP/maisaka_chat.prompt +28 -0
  210. src/_resources/prompts/ja-JP/maisaka_chat_focus.prompt +39 -0
  211. src/_resources/prompts/ja-JP/maisaka_chat_optimized.prompt +19 -0
  212. src/_resources/prompts/ja-JP/maisaka_replyer.prompt +10 -0
  213. src/_resources/prompts/ja-JP/memory_fuzzy_modify_plan.prompt +45 -0
  214. src/_resources/prompts/ja-JP/mid_term_memory_summary.prompt +10 -0
  215. src/_resources/prompts/ja-JP/reply_attachments.prompt +3 -0
  216. src/_resources/prompts/ja-JP/retro_private_replyer.prompt +13 -0
  217. src/_resources/prompts/ja-JP/retro_private_replyer_self.prompt +13 -0
  218. src/_resources/prompts/ja-JP/retro_replyer.prompt +17 -0
  219. src/_resources/prompts/ja-JP/retro_replyer_light.prompt +17 -0
  220. src/_resources/prompts/ja-JP/webui_ai_search.prompt +24 -0
  221. src/_resources/prompts/ja-JP/webui_ai_search_correction.prompt +1 -0
  222. src/_resources/prompts/ja-JP/webui_ai_search_evidence.prompt +3 -0
  223. src/_resources/prompts/ja-JP/webui_ai_search_final.prompt +1 -0
  224. src/_resources/prompts/ko/.meta.toml +4 -0
  225. src/_resources/prompts/ko/reply_attachments.prompt +3 -0
  226. src/_resources/prompts/zh-CN/.meta.toml +159 -0
  227. src/_resources/prompts/zh-CN/a_memorix_chat_summary.prompt +59 -0
  228. src/_resources/prompts/zh-CN/emoji_content_analysis.prompt +3 -0
  229. src/_resources/prompts/zh-CN/emoji_content_filtration.prompt +6 -0
  230. src/_resources/prompts/zh-CN/emoji_replace.prompt +12 -0
  231. src/_resources/prompts/zh-CN/emoji_selection.prompt +6 -0
  232. src/_resources/prompts/zh-CN/expression_evaluation.prompt +14 -0
  233. src/_resources/prompts/zh-CN/heuristic_memory_impression.prompt +15 -0
  234. src/_resources/prompts/zh-CN/image_description.prompt +1 -0
  235. src/_resources/prompts/zh-CN/jargon_compare_inference.prompt +14 -0
  236. src/_resources/prompts/zh-CN/jargon_inference_content_only.prompt +11 -0
  237. src/_resources/prompts/zh-CN/jargon_inference_with_context.prompt +19 -0
  238. src/_resources/prompts/zh-CN/learn_jargon.prompt +31 -0
  239. src/_resources/prompts/zh-CN/learn_style.prompt +28 -0
  240. src/_resources/prompts/zh-CN/maisaka_chat.prompt +28 -0
  241. src/_resources/prompts/zh-CN/maisaka_chat_focus.prompt +39 -0
  242. src/_resources/prompts/zh-CN/maisaka_chat_optimized.prompt +19 -0
  243. src/_resources/prompts/zh-CN/maisaka_replyer.prompt +6 -0
  244. src/_resources/prompts/zh-CN/memory_fuzzy_modify_plan.prompt +45 -0
  245. src/_resources/prompts/zh-CN/mid_term_memory_summary.prompt +10 -0
  246. src/_resources/prompts/zh-CN/reply_attachments.prompt +3 -0
  247. src/_resources/prompts/zh-CN/retro_private_replyer.prompt +13 -0
  248. src/_resources/prompts/zh-CN/retro_private_replyer_self.prompt +13 -0
  249. src/_resources/prompts/zh-CN/retro_replyer.prompt +17 -0
  250. src/_resources/prompts/zh-CN/retro_replyer_light.prompt +17 -0
  251. src/_resources/prompts/zh-CN/webui_ai_search.prompt +24 -0
  252. src/_resources/prompts/zh-CN/webui_ai_search_correction.prompt +1 -0
  253. src/_resources/prompts/zh-CN/webui_ai_search_evidence.prompt +3 -0
  254. src/_resources/prompts/zh-CN/webui_ai_search_final.prompt +1 -0
  255. src/_resources/pyproject.toml +156 -0
  256. src/chat/__init__.py +13 -0
  257. src/chat/heart_flow/heartFC_utils.py +29 -0
  258. src/chat/heart_flow/heartflow_manager.py +138 -0
  259. src/chat/heart_flow/heartflow_message_processor.py +104 -0
  260. src/chat/image_system/image_cache_cleanup.py +300 -0
  261. src/chat/image_system/image_manager.py +516 -0
  262. src/chat/message_receive/__init__.py +5 -0
  263. src/chat/message_receive/bot.py +893 -0
  264. src/chat/message_receive/chat_manager.py +540 -0
  265. src/chat/message_receive/image_receive_compressor.py +135 -0
  266. src/chat/message_receive/message.py +546 -0
  267. src/chat/message_receive/uni_message_sender.py +332 -0
  268. src/chat/replyer/expression_vector_index.py +3176 -0
  269. src/chat/replyer/maisaka_expression_selector.py +678 -0
  270. src/chat/replyer/maisaka_generator.py +25 -0
  271. src/chat/replyer/maisaka_generator_base.py +1457 -0
  272. src/chat/replyer/replyer_manager.py +62 -0
  273. src/chat/replyer/retro_prompt.py +512 -0
  274. src/chat/utils/llm_sentence_splitter.py +151 -0
  275. src/chat/utils/statistic.py +3170 -0
  276. src/chat/utils/typo_generator.py +495 -0
  277. src/chat/utils/utils.py +922 -0
  278. src/cli/__init__.py +3 -0
  279. src/cli/bot_console.py +281 -0
  280. src/cli/console.py +17 -0
  281. src/cli/input_reader.py +55 -0
  282. src/cli/maisaka_cli.py +122 -0
  283. src/cli/maisaka_cli_sender.py +28 -0
  284. src/cli/tool.py +62 -0
  285. src/common/__init__.py +1 -0
  286. src/common/data_models/__init__.py +28 -0
  287. src/common/data_models/chat_session_data_model.py +73 -0
  288. src/common/data_models/chat_target_info_data_model.py +73 -0
  289. src/common/data_models/embedding_service_data_models.py +31 -0
  290. src/common/data_models/expression_data_model.py +83 -0
  291. src/common/data_models/image_data_model.py +247 -0
  292. src/common/data_models/jargon_data_model.py +101 -0
  293. src/common/data_models/llm_data_model.py +23 -0
  294. src/common/data_models/llm_service_data_models.py +298 -0
  295. src/common/data_models/mai_message_data_model.py +226 -0
  296. src/common/data_models/message_component_data_model.py +560 -0
  297. src/common/data_models/person_info_data_model.py +177 -0
  298. src/common/data_models/reply_generation_data_models.py +275 -0
  299. src/common/data_models/tool_record_data_model.py +51 -0
  300. src/common/database/__init__.py +0 -0
  301. src/common/database/database.py +240 -0
  302. src/common/database/database_model.py +477 -0
  303. src/common/database/migrations/__init__.py +161 -0
  304. src/common/database/migrations/behavior_generic_tags.py +532 -0
  305. src/common/database/migrations/bootstrap.py +171 -0
  306. src/common/database/migrations/builtin.py +1966 -0
  307. src/common/database/migrations/exceptions.py +33 -0
  308. src/common/database/migrations/frozen_v2_schema.py +282 -0
  309. src/common/database/migrations/legacy_v1_to_v2.py +1300 -0
  310. src/common/database/migrations/manager.py +209 -0
  311. src/common/database/migrations/models.py +305 -0
  312. src/common/database/migrations/planner.py +108 -0
  313. src/common/database/migrations/progress.py +319 -0
  314. src/common/database/migrations/registry.py +98 -0
  315. src/common/database/migrations/resolver.py +135 -0
  316. src/common/database/migrations/schema.py +98 -0
  317. src/common/database/migrations/v10_to_v11.py +60 -0
  318. src/common/database/migrations/v11_to_v12.py +92 -0
  319. src/common/database/migrations/v12_to_v13.py +42 -0
  320. src/common/database/migrations/v13_to_v14.py +55 -0
  321. src/common/database/migrations/v14_to_v15.py +44 -0
  322. src/common/database/migrations/v15_to_v16.py +39 -0
  323. src/common/database/migrations/v16_to_v17.py +66 -0
  324. src/common/database/migrations/v17_to_v18.py +64 -0
  325. src/common/database/migrations/v18_to_v19.py +355 -0
  326. src/common/database/migrations/v19_to_v20.py +169 -0
  327. src/common/database/migrations/v20_to_v21.py +149 -0
  328. src/common/database/migrations/v21_to_v22.py +343 -0
  329. src/common/database/migrations/v22_to_v23.py +114 -0
  330. src/common/database/migrations/v23_to_v24.py +465 -0
  331. src/common/database/migrations/v24_to_v25.py +96 -0
  332. src/common/database/migrations/v25_to_v26.py +32 -0
  333. src/common/database/migrations/v26_to_v27.py +86 -0
  334. src/common/database/migrations/v27_to_v28.py +185 -0
  335. src/common/database/migrations/v28_to_v29.py +80 -0
  336. src/common/database/migrations/v29_to_v30.py +217 -0
  337. src/common/database/migrations/v2_to_v3.py +269 -0
  338. src/common/database/migrations/v30_to_v31.py +323 -0
  339. src/common/database/migrations/v31_to_v32.py +250 -0
  340. src/common/database/migrations/v32_to_v33.py +84 -0
  341. src/common/database/migrations/v33_to_v34.py +119 -0
  342. src/common/database/migrations/v34_to_v35.py +68 -0
  343. src/common/database/migrations/v35_to_v36.py +35 -0
  344. src/common/database/migrations/v36_to_v37.py +54 -0
  345. src/common/database/migrations/v37_to_v38.py +95 -0
  346. src/common/database/migrations/v38_to_v39.py +85 -0
  347. src/common/database/migrations/v39_to_v40.py +53 -0
  348. src/common/database/migrations/v3_to_v4.py +155 -0
  349. src/common/database/migrations/v40_to_v41.py +23 -0
  350. src/common/database/migrations/v41_to_v42.py +44 -0
  351. src/common/database/migrations/v42_to_v43.py +29 -0
  352. src/common/database/migrations/v4_to_v5.py +77 -0
  353. src/common/database/migrations/v5_to_v6.py +51 -0
  354. src/common/database/migrations/v6_to_v7.py +164 -0
  355. src/common/database/migrations/v7_to_v8.py +73 -0
  356. src/common/database/migrations/v8_to_v9.py +313 -0
  357. src/common/database/migrations/v9_to_v10.py +53 -0
  358. src/common/database/migrations/version_store.py +57 -0
  359. src/common/embedding_identity.py +43 -0
  360. src/common/event_loop_watchdog.py +72 -0
  361. src/common/i18n/__init__.py +70 -0
  362. src/common/i18n/exceptions.py +18 -0
  363. src/common/i18n/formatting.py +29 -0
  364. src/common/i18n/loaders.py +162 -0
  365. src/common/i18n/manager.py +201 -0
  366. src/common/logger.py +1009 -0
  367. src/common/logger_color_and_mapping.py +430 -0
  368. src/common/message_repository.py +287 -0
  369. src/common/message_server/__init__.py +19 -0
  370. src/common/message_server/api.py +190 -0
  371. src/common/message_server/server.py +130 -0
  372. src/common/operation_timing.py +52 -0
  373. src/common/paths.py +32 -0
  374. src/common/process_runner.py +142 -0
  375. src/common/prompt_i18n.py +416 -0
  376. src/common/remote.py +288 -0
  377. src/common/reply_effect_fingerprint.py +119 -0
  378. src/common/reply_effect_record_codec.py +40 -0
  379. src/common/runtime_loop.py +31 -0
  380. src/common/self_update.py +580 -0
  381. src/common/shutdown.py +55 -0
  382. src/common/update_notice.py +351 -0
  383. src/common/utils/image_path.py +39 -0
  384. src/common/utils/math_utils.py +122 -0
  385. src/common/utils/port_checker.py +97 -0
  386. src/common/utils/system_utils.py +11 -0
  387. src/common/utils/utils_action.py +32 -0
  388. src/common/utils/utils_config.py +613 -0
  389. src/common/utils/utils_file.py +56 -0
  390. src/common/utils/utils_image.py +257 -0
  391. src/common/utils/utils_message.py +871 -0
  392. src/common/utils/utils_person.py +55 -0
  393. src/common/utils/utils_session.py +42 -0
  394. src/common/utils/utils_voice.py +42 -0
  395. src/common/version.py +33 -0
  396. src/config/__init__.py +0 -0
  397. src/config/config.py +927 -0
  398. src/config/config_base.py +308 -0
  399. src/config/config_upgrade_hooks.py +675 -0
  400. src/config/config_utils.py +166 -0
  401. src/config/default_model_config.py +131 -0
  402. src/config/file_watcher.py +238 -0
  403. src/config/legacy_migration.py +424 -0
  404. src/config/legacy_upgrade_confirmation.py +225 -0
  405. src/config/model_configs.py +548 -0
  406. src/config/official_configs.py +6166 -0
  407. src/config/startup_bindings.py +201 -0
  408. src/core/__init__.py +6 -0
  409. src/core/announcement_manager.py +120 -0
  410. src/core/config_types.py +273 -0
  411. src/core/event_bus.py +225 -0
  412. src/core/local_operator.py +98 -0
  413. src/core/tooling.py +420 -0
  414. src/core/types.py +423 -0
  415. src/emoji_system/emoji_cache_cleanup.py +326 -0
  416. src/emoji_system/emoji_manager.py +1314 -0
  417. src/emoji_system/maisaka_tool.py +286 -0
  418. src/learners/expression_learner.py +927 -0
  419. src/learners/expression_review_store.py +216 -0
  420. src/learners/expression_style_utils.py +32 -0
  421. src/learners/expression_utils.py +299 -0
  422. src/learners/jargon_learner.py +873 -0
  423. src/learners/jargon_miner.py +885 -0
  424. src/learners/weighted_sampling.py +75 -0
  425. src/llm_models/LICENSE +21 -0
  426. src/llm_models/__init__.py +0 -0
  427. src/llm_models/exceptions.py +140 -0
  428. src/llm_models/generation_diagnostics.py +155 -0
  429. src/llm_models/image_normalizer.py +128 -0
  430. src/llm_models/model_client/__init__.py +29 -0
  431. src/llm_models/model_client/adapter_base.py +362 -0
  432. src/llm_models/model_client/base_client.py +678 -0
  433. src/llm_models/model_client/embedding_rate_limit.py +55 -0
  434. src/llm_models/model_client/gemini_client.py +1379 -0
  435. src/llm_models/model_client/image_embedding_protocols.py +397 -0
  436. src/llm_models/model_client/openai_client.py +2200 -0
  437. src/llm_models/model_client/openai_responses_client.py +817 -0
  438. src/llm_models/model_client/plugin_client.py +307 -0
  439. src/llm_models/openai_compat.py +165 -0
  440. src/llm_models/payload_content/__init__.py +51 -0
  441. src/llm_models/payload_content/context_item.py +699 -0
  442. src/llm_models/payload_content/context_protocol.py +243 -0
  443. src/llm_models/payload_content/native_tool.py +18 -0
  444. src/llm_models/payload_content/resp_format.py +239 -0
  445. src/llm_models/payload_content/tool_option.py +547 -0
  446. src/llm_models/request_snapshot.py +1311 -0
  447. src/llm_models/utils.py +276 -0
  448. src/llm_models/utils_model.py +1604 -0
  449. src/main.py +300 -0
  450. src/maisaka/attention_drift.py +116 -0
  451. src/maisaka/builtin_tool/__init__.py +236 -0
  452. src/maisaka/builtin_tool/context.py +530 -0
  453. src/maisaka/builtin_tool/fetch_history.py +62 -0
  454. src/maisaka/builtin_tool/provider.py +74 -0
  455. src/maisaka/builtin_tool/query_image_memory.py +103 -0
  456. src/maisaka/builtin_tool/query_memory.py +335 -0
  457. src/maisaka/builtin_tool/query_person_profile.py +152 -0
  458. src/maisaka/builtin_tool/read_tool_result.py +142 -0
  459. src/maisaka/builtin_tool/reply.py +695 -0
  460. src/maisaka/builtin_tool/send_emoji.py +543 -0
  461. src/maisaka/builtin_tool/show_emoji_list.py +76 -0
  462. src/maisaka/builtin_tool/subagent.py +549 -0
  463. src/maisaka/builtin_tool/switch_chat.py +78 -0
  464. src/maisaka/builtin_tool/tool_search.py +92 -0
  465. src/maisaka/builtin_tool/view_forward_message.py +153 -0
  466. src/maisaka/builtin_tool/wait.py +77 -0
  467. src/maisaka/chat_loop_service.py +1579 -0
  468. src/maisaka/context/__init__.py +1 -0
  469. src/maisaka/context/clear_context.py +42 -0
  470. src/maisaka/context/emoji_candidates.py +36 -0
  471. src/maisaka/context/history.py +384 -0
  472. src/maisaka/context/image_attachment.py +52 -0
  473. src/maisaka/context/message_adapter.py +126 -0
  474. src/maisaka/context/message_id_alias.py +52 -0
  475. src/maisaka/context/messages.py +825 -0
  476. src/maisaka/context/planner_messages.py +199 -0
  477. src/maisaka/context/post_processor.py +204 -0
  478. src/maisaka/context/usage.py +141 -0
  479. src/maisaka/display/__init__.py +25 -0
  480. src/maisaka/display/display_utils.py +144 -0
  481. src/maisaka/display/preview_path_utils.py +64 -0
  482. src/maisaka/display/prompt_cli_renderer.py +1110 -0
  483. src/maisaka/display/prompt_preview_logger.py +603 -0
  484. src/maisaka/display/runtime_mixin.py +709 -0
  485. src/maisaka/display/stage_status_board.py +129 -0
  486. src/maisaka/focus/__init__.py +10 -0
  487. src/maisaka/focus/manager.py +442 -0
  488. src/maisaka/focus/runtime_mixin.py +753 -0
  489. src/maisaka/jargon_context_matcher.py +363 -0
  490. src/maisaka/memory/__init__.py +1 -0
  491. src/maisaka/memory/heuristic_injector.py +393 -0
  492. src/maisaka/memory/image_injector.py +81 -0
  493. src/maisaka/memory/mid_term.py +995 -0
  494. src/maisaka/memory/person_profile.py +280 -0
  495. src/maisaka/mode_policy.py +28 -0
  496. src/maisaka/monitor/__init__.py +1 -0
  497. src/maisaka/monitor/event_store.py +351 -0
  498. src/maisaka/monitor/events.py +795 -0
  499. src/maisaka/monitor/message_payload.py +172 -0
  500. src/maisaka/monitor/planner_delta.py +77 -0
  501. src/maisaka/monitor/tool_media.py +58 -0
  502. src/maisaka/planner_reply_guard.py +69 -0
  503. src/maisaka/reasoning_engine.py +2186 -0
  504. src/maisaka/reply_effect/__init__.py +18 -0
  505. src/maisaka/reply_effect/image_utils.py +100 -0
  506. src/maisaka/reply_effect/judge.py +382 -0
  507. src/maisaka/reply_effect/models.py +185 -0
  508. src/maisaka/reply_effect/path_utils.py +24 -0
  509. src/maisaka/reply_effect/quote_utils.py +32 -0
  510. src/maisaka/reply_effect/scoring.py +205 -0
  511. src/maisaka/reply_effect/storage.py +380 -0
  512. src/maisaka/reply_effect/tracker.py +568 -0
  513. src/maisaka/runtime.py +2218 -0
  514. src/maisaka/subagent_tasks.py +171 -0
  515. src/maisaka/tool_result_condenser.py +143 -0
  516. src/maisaka/tool_result_store.py +57 -0
  517. src/maisaka/turn_trigger/__init__.py +9 -0
  518. src/maisaka/turn_trigger/dynamic_gate.py +211 -0
  519. src/maisaka/turn_trigger/gates.py +252 -0
  520. src/maisaka/turn_trigger/idle_backoff.py +93 -0
  521. src/maisaka/turn_trigger/reply_likelihood.py +88 -0
  522. src/maisaka/turn_trigger/scheduler.py +113 -0
  523. src/maisaka/utils/__init__.py +1 -0
  524. src/maisaka/utils/tool_post_execution.py +76 -0
  525. src/maisaka/utils/tool_record_payload.py +155 -0
  526. src/maisaka/visual/__init__.py +1 -0
  527. src/maisaka/visual/chat_history_refresher.py +303 -0
  528. src/maisaka/visual/message_limiter.py +69 -0
  529. src/maisaka/visual/mode_utils.py +33 -0
  530. src/manager/async_task_manager.py +193 -0
  531. src/manager/local_store_manager.py +126 -0
  532. src/mcp_module/__init__.py +9 -0
  533. src/mcp_module/config.py +174 -0
  534. src/mcp_module/connection.py +726 -0
  535. src/mcp_module/hooks.py +20 -0
  536. src/mcp_module/host_llm_bridge.py +654 -0
  537. src/mcp_module/manager.py +680 -0
  538. src/mcp_module/models.py +437 -0
  539. src/mcp_module/provider.py +62 -0
  540. src/mcp_module/service.py +308 -0
  541. src/mcp_module/stdio_filter.py +177 -0
  542. src/person_info/__init__.py +0 -0
  543. src/person_info/person_info.py +600 -0
  544. src/platform_io/__init__.py +39 -0
  545. src/platform_io/adapter_policy.py +839 -0
  546. src/platform_io/avatar.py +197 -0
  547. src/platform_io/dedupe.py +133 -0
  548. src/platform_io/drivers/__init__.py +11 -0
  549. src/platform_io/drivers/base.py +104 -0
  550. src/platform_io/drivers/legacy_driver.py +92 -0
  551. src/platform_io/drivers/plugin_driver.py +213 -0
  552. src/platform_io/manager.py +716 -0
  553. src/platform_io/outbound_tracker.py +286 -0
  554. src/platform_io/registry.py +70 -0
  555. src/platform_io/route_key_factory.py +174 -0
  556. src/platform_io/routing.py +141 -0
  557. src/platform_io/types.py +265 -0
  558. src/plugin_runtime/__init__.py +60 -0
  559. src/plugin_runtime/avatar_provider.py +64 -0
  560. src/plugin_runtime/capabilities/__init__.py +11 -0
  561. src/plugin_runtime/capabilities/components.py +977 -0
  562. src/plugin_runtime/capabilities/core.py +868 -0
  563. src/plugin_runtime/capabilities/data.py +1415 -0
  564. src/plugin_runtime/capabilities/registry.py +116 -0
  565. src/plugin_runtime/capabilities/render.py +131 -0
  566. src/plugin_runtime/compat_policy.py +47 -0
  567. src/plugin_runtime/component_query.py +1119 -0
  568. src/plugin_runtime/dependency_pipeline.py +547 -0
  569. src/plugin_runtime/docker_layout_migration.py +275 -0
  570. src/plugin_runtime/hook_catalog.py +52 -0
  571. src/plugin_runtime/hook_payloads.py +197 -0
  572. src/plugin_runtime/hook_schema_utils.py +31 -0
  573. src/plugin_runtime/host/__init__.py +1 -0
  574. src/plugin_runtime/host/api_registry.py +353 -0
  575. src/plugin_runtime/host/authorization.py +67 -0
  576. src/plugin_runtime/host/capability_service.py +91 -0
  577. src/plugin_runtime/host/circuit_breaker.py +243 -0
  578. src/plugin_runtime/host/component_registry.py +1336 -0
  579. src/plugin_runtime/host/component_timeout.py +32 -0
  580. src/plugin_runtime/host/event_dispatcher.py +228 -0
  581. src/plugin_runtime/host/hook_dispatcher.py +711 -0
  582. src/plugin_runtime/host/hook_spec_registry.py +190 -0
  583. src/plugin_runtime/host/logger_bridge.py +64 -0
  584. src/plugin_runtime/host/message_gateway.py +116 -0
  585. src/plugin_runtime/host/message_utils.py +572 -0
  586. src/plugin_runtime/host/reply_extension_schema.py +65 -0
  587. src/plugin_runtime/host/reply_extensions.py +204 -0
  588. src/plugin_runtime/host/rpc_server.py +509 -0
  589. src/plugin_runtime/host/supervisor.py +2269 -0
  590. src/plugin_runtime/integration.py +2063 -0
  591. src/plugin_runtime/local_sdk.py +147 -0
  592. src/plugin_runtime/protocol/__init__.py +1 -0
  593. src/plugin_runtime/protocol/codec.py +71 -0
  594. src/plugin_runtime/protocol/envelope.py +600 -0
  595. src/plugin_runtime/protocol/errors.py +77 -0
  596. src/plugin_runtime/runner/__init__.py +1 -0
  597. src/plugin_runtime/runner/log_handler.py +239 -0
  598. src/plugin_runtime/runner/manifest_validator.py +1517 -0
  599. src/plugin_runtime/runner/plugin_loader.py +711 -0
  600. src/plugin_runtime/runner/plugin_paths.py +59 -0
  601. src/plugin_runtime/runner/rpc_client.py +336 -0
  602. src/plugin_runtime/runner/runner_main.py +2670 -0
  603. src/plugin_runtime/tool_provider.py +58 -0
  604. src/plugin_runtime/transport/__init__.py +1 -0
  605. src/plugin_runtime/transport/base.py +116 -0
  606. src/plugin_runtime/transport/factory.py +57 -0
  607. src/plugin_runtime/transport/named_pipe.py +206 -0
  608. src/plugin_runtime/transport/tcp.py +62 -0
  609. src/plugin_runtime/transport/uds.py +133 -0
  610. src/plugin_runtime/update_compatibility_notice.py +303 -0
  611. src/plugin_runtime/upload_store.py +173 -0
  612. src/plugin_runtime/webui_schema.py +571 -0
  613. src/plugins/__init__.py +1 -0
  614. src/plugins/built_in/plugin_management/_manifest.json +48 -0
  615. src/plugins/built_in/plugin_management/plugin.py +665 -0
  616. src/prompt/prompt_manager.py +440 -0
  617. src/runtime.py +688 -0
  618. src/services/__init__.py +7 -0
  619. src/services/bot_account_service.py +327 -0
  620. src/services/database_service.py +203 -0
  621. src/services/embedding_service.py +271 -0
  622. src/services/expression_vector_service.py +106 -0
  623. src/services/html_render_service.py +945 -0
  624. src/services/image_path_maintenance_service.py +454 -0
  625. src/services/image_writeback_journal.py +98 -0
  626. src/services/llm_cache_stats.py +1521 -0
  627. src/services/llm_service.py +799 -0
  628. src/services/memory_flow_service.py +1019 -0
  629. src/services/memory_service.py +528 -0
  630. src/services/message_service.py +295 -0
  631. src/services/message_word_frequency_service.py +595 -0
  632. src/services/news_service.py +123 -0
  633. src/services/person_fact_reverification.py +90 -0
  634. src/services/person_fact_verifier.py +79 -0
  635. src/services/send_service.py +1483 -0
  636. src/services/service_task_resolver.py +59 -0
  637. src/services/statistics_aggregation_service.py +349 -0
  638. src/services/statistics_service.py +850 -0
  639. src/services/telemetry_stats_service.py +294 -0
  640. src/services/tool_record_cleanup_service.py +205 -0
  641. src/webui/app.py +374 -0
  642. src/webui/config_schema.py +481 -0
  643. src/webui/core/__init__.py +32 -0
  644. src/webui/core/auth.py +153 -0
  645. src/webui/core/rate_limiter.py +247 -0
  646. src/webui/core/security.py +361 -0
  647. src/webui/dependencies.py +70 -0
  648. src/webui/logs_ws.py +195 -0
  649. src/webui/middleware/__init__.py +17 -0
  650. src/webui/middleware/anti_crawler.py +824 -0
  651. src/webui/middleware/compression.py +88 -0
  652. src/webui/routers/__init__.py +33 -0
  653. src/webui/routers/avatar.py +250 -0
  654. src/webui/routers/bot_accounts.py +103 -0
  655. src/webui/routers/chat/__init__.py +18 -0
  656. src/webui/routers/chat/routes.py +1715 -0
  657. src/webui/routers/chat/serializers.py +248 -0
  658. src/webui/routers/chat/service.py +1489 -0
  659. src/webui/routers/config.py +1964 -0
  660. src/webui/routers/data_transfer.py +769 -0
  661. src/webui/routers/emoji/__init__.py +3 -0
  662. src/webui/routers/emoji/routes.py +1230 -0
  663. src/webui/routers/emoji/schemas.py +182 -0
  664. src/webui/routers/emoji/support.py +143 -0
  665. src/webui/routers/expression.py +2298 -0
  666. src/webui/routers/jargon.py +1319 -0
  667. src/webui/routers/mcp.py +99 -0
  668. src/webui/routers/memory.py +5589 -0
  669. src/webui/routers/model.py +910 -0
  670. src/webui/routers/news.py +23 -0
  671. src/webui/routers/person.py +427 -0
  672. src/webui/routers/plugin/__init__.py +31 -0
  673. src/webui/routers/plugin/catalog.py +218 -0
  674. src/webui/routers/plugin/config_routes.py +592 -0
  675. src/webui/routers/plugin/icon_routes.py +71 -0
  676. src/webui/routers/plugin/management.py +908 -0
  677. src/webui/routers/plugin/marketplace.py +116 -0
  678. src/webui/routers/plugin/progress.py +172 -0
  679. src/webui/routers/plugin/release_install.py +313 -0
  680. src/webui/routers/plugin/releases.py +181 -0
  681. src/webui/routers/plugin/runtime_routes.py +243 -0
  682. src/webui/routers/plugin/schemas.py +131 -0
  683. src/webui/routers/plugin/stats_proxy.py +127 -0
  684. src/webui/routers/plugin/support.py +366 -0
  685. src/webui/routers/plugin/webui_extensions.py +346 -0
  686. src/webui/routers/plugin/zip_install.py +125 -0
  687. src/webui/routers/reasoning_process.py +2326 -0
  688. src/webui/routers/reply_effects.py +926 -0
  689. src/webui/routers/search.py +249 -0
  690. src/webui/routers/self_update.py +194 -0
  691. src/webui/routers/statistics.py +61 -0
  692. src/webui/routers/system.py +1790 -0
  693. src/webui/routers/user_emoji.py +168 -0
  694. src/webui/routers/websocket/__init__.py +7 -0
  695. src/webui/routers/websocket/auth.py +104 -0
  696. src/webui/routers/websocket/manager.py +373 -0
  697. src/webui/routers/websocket/plugin_runtime.py +54 -0
  698. src/webui/routers/websocket/unified.py +761 -0
  699. src/webui/routes.py +416 -0
  700. src/webui/schemas/__init__.py +18 -0
  701. src/webui/schemas/news.py +24 -0
  702. src/webui/schemas/statistics.py +173 -0
  703. src/webui/services/__init__.py +1 -0
  704. src/webui/services/ai_search_agent.py +509 -0
  705. src/webui/services/ai_search_documents.py +329 -0
  706. src/webui/services/ai_search_grounding.py +103 -0
  707. src/webui/services/ai_search_local_config.py +129 -0
  708. src/webui/services/ai_search_models.py +98 -0
  709. src/webui/services/git_mirror_service.py +1024 -0
  710. src/webui/services/plugin_market_service.py +56 -0
  711. src/webui/utils/__init__.py +1 -0
  712. src/webui/utils/http_client.py +27 -0
  713. src/webui/utils/network_security.py +133 -0
  714. src/webui/utils/toml_utils.py +124 -0
  715. src/webui/version_compatibility.py +180 -0
  716. src/webui/webui_server.py +422 -0
@@ -0,0 +1,2200 @@
1
+ from dataclasses import dataclass, field
2
+ from typing import Any, Callable, Coroutine, Dict, List, Set, Tuple, cast
3
+ from urllib.parse import urlparse
4
+ from uuid import uuid4
5
+
6
+ import asyncio
7
+ import base64
8
+ import binascii
9
+ import io
10
+ import json
11
+ import re
12
+
13
+ from json_repair import repair_json
14
+ from openai import APIConnectionError, APIStatusError, AsyncOpenAI, AsyncStream
15
+ from openai._types import FileTypes, Omit, omit
16
+ from openai.types.chat import (
17
+ ChatCompletion,
18
+ ChatCompletionAssistantMessageParam,
19
+ ChatCompletionChunk,
20
+ ChatCompletionContentPartImageParam,
21
+ ChatCompletionContentPartParam,
22
+ ChatCompletionContentPartTextParam,
23
+ ChatCompletionMessageFunctionToolCallParam,
24
+ ChatCompletionMessageParam,
25
+ ChatCompletionSystemMessageParam,
26
+ ChatCompletionToolMessageParam,
27
+ ChatCompletionToolParam,
28
+ ChatCompletionUserMessageParam,
29
+ )
30
+ from openai.types.shared_params.function_definition import FunctionDefinition
31
+ from openai.types.chat.chat_completion_chunk import ChoiceDelta
32
+ from PIL import Image as PILImage
33
+
34
+ from src.common.logger import get_logger
35
+ from src.config.model_configs import APIProvider, ReasoningParseMode, ToolArgumentParseMode
36
+ from src.llm_models.exceptions import (
37
+ EmptyResponseException,
38
+ InterruptedStreamOutput,
39
+ NetworkConnectionError,
40
+ ReqAbortException,
41
+ RespNotOkException,
42
+ RespParseException,
43
+ )
44
+ from src.llm_models.openai_compat import (
45
+ build_openai_compatible_client_config,
46
+ split_openai_request_overrides,
47
+ validate_image_embedding_transport,
48
+ )
49
+ from src.llm_models.payload_content.context_item import (
50
+ AssistantMessageItem,
51
+ ContextImagePart,
52
+ ContextItem,
53
+ ContextRefusalPart,
54
+ ContextTextPart,
55
+ ContextToolCall,
56
+ FunctionCallItem,
57
+ FunctionCallOutputItem,
58
+ ProviderActivityItem,
59
+ ProviderOpaqueItem,
60
+ ReasoningItem,
61
+ SystemMessageItem,
62
+ UserMessageItem,
63
+ build_portable_output_items,
64
+ )
65
+ from src.llm_models.payload_content.resp_format import RespFormat, RespFormatType
66
+ from src.llm_models.payload_content.tool_option import (
67
+ TOOL_CALL_SOURCE_EXTRA_KEY,
68
+ TOOL_CALL_SOURCE_REASONING,
69
+ TOOL_CALL_SOURCE_RESPONSE,
70
+ ToolCall,
71
+ ToolOption,
72
+ )
73
+
74
+ from .adapter_base import (
75
+ AdapterClient,
76
+ ProviderResponseParser,
77
+ ProviderStreamResponseHandler,
78
+ await_task_with_interrupt,
79
+ )
80
+ from .base_client import (
81
+ APIResponse,
82
+ AudioTranscriptionRequest,
83
+ EmbeddingRequest,
84
+ ImageEmbeddingRequest,
85
+ RequestTraceContext,
86
+ ResponseRequest,
87
+ UsageTuple,
88
+ client_registry,
89
+ )
90
+ from .image_embedding_protocols import (
91
+ build_compatible_image_embedding_fingerprint,
92
+ build_native_image_embedding_diagnostic_payload,
93
+ build_native_image_embedding_fingerprint,
94
+ build_native_image_embedding_request,
95
+ parse_native_image_embedding_response,
96
+ resolve_compatible_image_embedding_input,
97
+ resolve_native_image_embedding_protocol,
98
+ )
99
+ from ..request_snapshot import (
100
+ attach_request_snapshot,
101
+ has_request_snapshot,
102
+ save_failed_request_snapshot,
103
+ serialize_audio_request_snapshot,
104
+ serialize_embedding_request_snapshot,
105
+ serialize_image_embedding_request_snapshot,
106
+ serialize_response_request_snapshot,
107
+ )
108
+
109
+ logger = get_logger("llm_models")
110
+
111
+ SUPPORTED_OPENAI_IMAGE_FORMATS = {"jpeg", "png", "webp"}
112
+ """OpenAI 兼容图片输入稳定支持的格式集合。"""
113
+
114
+
115
+ def _render_image_embedding_template(value: Any, replacements: Dict[str, str]) -> Any:
116
+ """递归替换图片嵌入输入模板中的受控占位符。"""
117
+
118
+ if isinstance(value, str):
119
+ rendered = value
120
+ for name, replacement in replacements.items():
121
+ rendered = rendered.replace("{" + name + "}", replacement)
122
+ return rendered
123
+ if isinstance(value, list):
124
+ return [_render_image_embedding_template(item, replacements) for item in value]
125
+ if isinstance(value, dict):
126
+ return {str(key): _render_image_embedding_template(item, replacements) for key, item in value.items()}
127
+ return value
128
+
129
+ THINK_CONTENT_PATTERN = re.compile(
130
+ r"<think>(?P<think>.*?)</think>(?P<content>.*)|<think>(?P<think_unclosed>.*)|(?P<content_only>.+)",
131
+ re.DOTALL,
132
+ )
133
+ """用于解析 `<think>` 推理块的正则表达式。"""
134
+
135
+ XML_TOOL_CALL_PATTERN = re.compile(r"<tool_call>\s*(?P<body>.*?)\s*</tool_call>", re.DOTALL | re.IGNORECASE)
136
+ """用于兜底解析模型以 XML 文本返回的工具调用。
137
+
138
+ 这是一个暂时性兼容方案,专门处理“思维链内容里夹带工具调用”的情况;
139
+ 后续如果上游稳定返回标准 tool_calls 字段,这里可能会调整或移除。
140
+ """
141
+
142
+ XML_FUNCTION_CALL_PATTERN = re.compile(
143
+ r"<function=(?P<name>[A-Za-z0-9_.-]+)>\s*(?P<arguments>.*?)\s*</function>",
144
+ re.DOTALL | re.IGNORECASE,
145
+ )
146
+ """用于从 XML 风格工具调用块中提取函数名与参数。"""
147
+
148
+ XML_PARAMETER_PATTERN = re.compile(
149
+ r"<parameter=(?P<name>[A-Za-z0-9_.-]+)>\s*(?P<value>.*?)\s*</parameter>",
150
+ re.DOTALL | re.IGNORECASE,
151
+ )
152
+ """用于从 XML 风格工具调用块中提取参数列表。"""
153
+
154
+ CHAT_COMPLETIONS_RESERVED_EXTRA_BODY_KEYS = {
155
+ "max_tokens",
156
+ "messages",
157
+ "model",
158
+ "response_format",
159
+ "stream",
160
+ "temperature",
161
+ "tools",
162
+ }
163
+ """由当前客户端显式承载、不应再落入 `extra_body` 的字段集合。"""
164
+
165
+ _SDK_AUTH_PLACEHOLDER_API_KEY = "maibot-sdk-auth-disabled"
166
+ """鉴权不交给 SDK 时传给 `AsyncOpenAI` 的占位密钥。
167
+
168
+ OpenAI SDK 2.34+ 构造时拒绝空 `api_key`,且每次请求要么带 `Authorization` 头、要么显式省略它;
169
+ 占位密钥只用于通过构造校验,它生成的 `Authorization` 头会被自定义鉴权头覆盖或在请求时被省略。"""
170
+
171
+ _AUTHORIZATION_HEADER = "Authorization"
172
+
173
+
174
+ def _canonicalize_authorization_header(headers: Dict[str, str]) -> Dict[str, str]:
175
+ """把任意大小写的 Authorization 请求头统一为标准写法。
176
+
177
+ SDK 按精确键名合并请求头:大小写不同的同名头会与占位密钥生成的 `Authorization` 头同时发出,
178
+ 或被请求级的省略标记误删,所以先统一键名。
179
+ """
180
+
181
+ return {
182
+ (_AUTHORIZATION_HEADER if key.lower() == _AUTHORIZATION_HEADER.lower() else key): value
183
+ for key, value in headers.items()
184
+ }
185
+
186
+
187
+ _MODELS_REQUIRING_MAX_COMPLETION_TOKENS: Set[Tuple[str, str]] = set()
188
+ """记录本进程内已确认「仅支持 max_completion_tokens」的模型,键为 (base_url, model_identifier)。
189
+ 命中后直接使用 max_completion_tokens,避免重复触发首次失败重试。"""
190
+
191
+ OpenAIStreamResponseHandler = Callable[
192
+ [AsyncStream[ChatCompletionChunk], asyncio.Event | None],
193
+ Coroutine[Any, Any, Tuple[APIResponse, UsageTuple | None]],
194
+ ]
195
+ """OpenAI 流式响应处理函数类型。"""
196
+
197
+ OpenAIResponseParser = Callable[[ChatCompletion], Tuple[APIResponse, UsageTuple | None]]
198
+ """OpenAI 非流式响应解析函数类型。"""
199
+
200
+ PROVIDER_REASONING_KEYS_BY_DOMAIN: Dict[str, str] = {
201
+ "api.groq.com": "reasoning",
202
+ "openrouter.ai": "reasoning",
203
+ }
204
+ """按 provider 域名指定的原生推理字段名。"""
205
+
206
+
207
+ def _build_fallback_tool_call_id(prefix: str) -> str:
208
+ """为缺失原始调用 ID 的工具调用生成唯一兜底标识。"""
209
+
210
+ normalized_prefix = str(prefix).strip() or "tool_call"
211
+ return f"{normalized_prefix}_{uuid4().hex}"
212
+
213
+
214
+ def _build_tool_call_extra_content(source: str) -> Dict[str, Any]:
215
+ """构造工具调用来源元信息。"""
216
+
217
+ return {TOOL_CALL_SOURCE_EXTRA_KEY: source}
218
+
219
+
220
+ def _set_tool_call_source(tool_call: ToolCall, source: str) -> ToolCall:
221
+ """给工具调用补充来源标记,保留已有 provider metadata。"""
222
+
223
+ extra_content = dict(tool_call.extra_content or {})
224
+ extra_content[TOOL_CALL_SOURCE_EXTRA_KEY] = source
225
+ tool_call.extra_content = extra_content
226
+ return tool_call
227
+
228
+
229
+ def _build_reasoning_key(api_provider: APIProvider) -> str:
230
+ """根据 provider 构建可读取的原生推理字段名。"""
231
+ normalized_base_url = api_provider.base_url.strip()
232
+ if not normalized_base_url:
233
+ return "reasoning_content"
234
+ parsed_url = urlparse(normalized_base_url if "://" in normalized_base_url else f"//{normalized_base_url}")
235
+ provider_hostname = (parsed_url.hostname or "").lower()
236
+ return PROVIDER_REASONING_KEYS_BY_DOMAIN.get(provider_hostname, "reasoning_content")
237
+
238
+
239
+ def _extract_reasoning_content(message_part: Any, reasoning_key: str) -> str | None:
240
+ """从 OpenAI 兼容响应对象中读取原生推理内容。
241
+
242
+ 不同兼容服务商对推理字段命名并不完全一致。这里集中处理字段访问,
243
+ 避免解析路径里散落 provider 特判;优先读取 provider 指定字段,
244
+ 读取不到时再尝试兼容 ``reasoning`` 字段。
245
+ """
246
+ native_reasoning = getattr(message_part, reasoning_key, None)
247
+ if isinstance(native_reasoning, str) and native_reasoning:
248
+ return native_reasoning
249
+
250
+ fallback_reasoning = getattr(message_part, "reasoning", None)
251
+ if isinstance(fallback_reasoning, str) and fallback_reasoning:
252
+ return fallback_reasoning
253
+ return None
254
+
255
+
256
+ def _normalize_reasoning_parse_mode(parse_mode: str | ReasoningParseMode) -> ReasoningParseMode:
257
+ """将配置中的推理解析模式收敛为枚举值。
258
+
259
+ Args:
260
+ parse_mode: 原始解析模式配置。
261
+
262
+ Returns:
263
+ ReasoningParseMode: 规范化后的解析模式;未知值会回退为 `AUTO`。
264
+ """
265
+ if isinstance(parse_mode, ReasoningParseMode):
266
+ return parse_mode
267
+ try:
268
+ return ReasoningParseMode(parse_mode)
269
+ except ValueError:
270
+ logger.warning(f"未识别的推理解析模式 {parse_mode},已回退为 auto")
271
+ return ReasoningParseMode.AUTO
272
+
273
+
274
+ def _normalize_tool_argument_parse_mode(parse_mode: str | ToolArgumentParseMode) -> ToolArgumentParseMode:
275
+ """将配置中的工具参数解析模式收敛为枚举值。
276
+
277
+ Args:
278
+ parse_mode: 原始解析模式配置。
279
+
280
+ Returns:
281
+ ToolArgumentParseMode: 规范化后的解析模式;未知值会回退为 `AUTO`。
282
+ """
283
+ if isinstance(parse_mode, ToolArgumentParseMode):
284
+ return parse_mode
285
+ try:
286
+ return ToolArgumentParseMode(parse_mode)
287
+ except ValueError:
288
+ logger.warning(f"未识别的工具参数解析模式 {parse_mode},已回退为 auto")
289
+ return ToolArgumentParseMode.AUTO
290
+
291
+
292
+ def _build_text_content_part(text: str) -> ChatCompletionContentPartTextParam:
293
+ """构建文本内容片段。
294
+
295
+ Args:
296
+ text: 文本内容。
297
+
298
+ Returns:
299
+ ChatCompletionContentPartTextParam: OpenAI 兼容的文本片段。
300
+ """
301
+ return {
302
+ "type": "text",
303
+ "text": text,
304
+ }
305
+
306
+
307
+ def _build_image_content_part(part: ContextImagePart) -> ChatCompletionContentPartImageParam:
308
+ """构建图片内容片段。
309
+
310
+ Args:
311
+ part: 内部图片片段。
312
+
313
+ Returns:
314
+ ChatCompletionContentPartImageParam: OpenAI 兼容的图片片段。
315
+ """
316
+ normalized_image = _normalize_image_part_for_openai(part)
317
+ if normalized_image is None:
318
+ raise ValueError("图片数据无效,无法构建图片消息片段")
319
+
320
+ image_format, image_base64 = normalized_image
321
+ return {
322
+ "type": "image_url",
323
+ "image_url": {
324
+ "url": f"data:image/{image_format};base64,{image_base64}",
325
+ },
326
+ }
327
+
328
+
329
+ def _normalize_image_part_for_openai(part: ContextImagePart) -> Tuple[str, str] | None:
330
+ """将图片片段规范化为 OpenAI 兼容格式。
331
+
332
+ Args:
333
+ part: 内部图片片段。
334
+
335
+ Returns:
336
+ Tuple[str, str] | None: `(image_format, image_base64)`;无法解析时返回 `None`。
337
+ """
338
+ try:
339
+ image_bytes = base64.b64decode(part.image_base64, validate=True)
340
+ except (binascii.Error, ValueError) as exc:
341
+ logger.warning(f"图片 Base64 解码失败,已跳过该图片片段: {exc}")
342
+ return None
343
+
344
+ try:
345
+ with PILImage.open(io.BytesIO(image_bytes)) as image:
346
+ image_format = (image.format or part.normalized_image_format).lower()
347
+ if image_format in {"jpg", "jpeg"}:
348
+ image_format = "jpeg"
349
+
350
+ if image_format in SUPPORTED_OPENAI_IMAGE_FORMATS:
351
+ return image_format, part.image_base64
352
+
353
+ if image_format == "gif":
354
+ # 部分 OpenAI 兼容视觉接口会拒绝动画 GIF 或动画 WebP,因此只传递首帧。
355
+ image.seek(0)
356
+ first_frame = image.copy()
357
+ if first_frame.mode not in {"RGB", "RGBA"}:
358
+ first_frame = first_frame.convert("RGBA")
359
+ output_buffer = io.BytesIO()
360
+ first_frame.save(output_buffer, format="PNG")
361
+ converted_base64 = base64.b64encode(output_buffer.getvalue()).decode("utf-8")
362
+ return "png", converted_base64
363
+
364
+ image.seek(0)
365
+ normalized_image = image.copy()
366
+ if normalized_image.mode not in {"RGB", "RGBA"}:
367
+ normalized_image = normalized_image.convert("RGBA")
368
+
369
+ output_buffer = io.BytesIO()
370
+ normalized_image.save(output_buffer, format="PNG")
371
+ converted_base64 = base64.b64encode(output_buffer.getvalue()).decode("utf-8")
372
+ return "png", converted_base64
373
+ except Exception as exc:
374
+ logger.warning(f"图片内容无法被识别为有效图片,已跳过该图片片段: {exc}")
375
+ return None
376
+
377
+
378
+ def _convert_response_format(response_format: RespFormat | None) -> Any:
379
+ """将内部响应格式转换为 OpenAI 兼容结构。
380
+
381
+ Args:
382
+ response_format: 内部响应格式定义。
383
+
384
+ Returns:
385
+ Any: OpenAI SDK 可接受的响应格式参数;未指定时返回 `omit`。
386
+ """
387
+ if response_format is None or response_format.format_type == RespFormatType.TEXT:
388
+ return omit
389
+ if response_format.format_type == RespFormatType.JSON_OBJ:
390
+ return {"type": "json_object"}
391
+ if response_format.format_type == RespFormatType.JSON_SCHEMA:
392
+ return {
393
+ "type": "json_schema",
394
+ "json_schema": response_format.schema,
395
+ }
396
+ return omit
397
+
398
+
399
+ def _get_portable_text_part(part: Any) -> str | None:
400
+ """返回文本或拒答片段的跨端点可见文本。"""
401
+
402
+ if isinstance(part, ContextTextPart):
403
+ return part.text
404
+ if isinstance(part, ContextRefusalPart):
405
+ return part.refusal
406
+ return None
407
+
408
+
409
+ def _convert_text_only_message_content(message: SystemMessageItem | AssistantMessageItem) -> str:
410
+ """将仅允许文本的消息转换为 OpenAI 兼容内容。
411
+
412
+ 部分 OpenAI 兼容服务(如阿里云百炼)只接受字符串形式的 system content,
413
+ 因此无论消息由几个文本片段组成,都按顺序直接拼接为一个字符串发送。
414
+
415
+ Args:
416
+ message: 内部统一消息对象。
417
+
418
+ Returns:
419
+ str: 拼接后的文本内容。
420
+
421
+ Raises:
422
+ ValueError: 当消息中包含非文本片段时抛出。
423
+ """
424
+ text_parts: List[str] = []
425
+ for part in message.parts:
426
+ part_text = _get_portable_text_part(part)
427
+ if part_text is None:
428
+ raise ValueError(f"{message.role.value} 消息仅支持文本片段")
429
+ text_parts.append(part_text)
430
+ return "".join(text_parts)
431
+
432
+
433
+ def _convert_user_message_content(message: UserMessageItem) -> str | List[ChatCompletionContentPartParam]:
434
+ """将用户消息转换为 OpenAI 兼容内容。
435
+
436
+ Args:
437
+ message: 内部统一消息对象。
438
+
439
+ Returns:
440
+ str | List[ChatCompletionContentPartParam]: 用户消息内容结构。
441
+ """
442
+ # 纯文本消息统一发送字符串:部分 OpenAI 兼容服务(如阿里云百炼)只接受字符串 content,
443
+ # 收到多段 text 数组会返回 400 "Input should be a valid string"。
444
+ # 文本片段是同一条消息按组件顺序切分出的连续内容,片段自身已带有所需的空白与换行,
445
+ # 因此与 get_item_text 一致地直接拼接,不额外插入分隔符;含图片时仍需使用数组。
446
+ text_parts = [part.text for part in message.parts if isinstance(part, ContextTextPart)]
447
+ if len(text_parts) == len(message.parts):
448
+ return "".join(text_parts)
449
+
450
+ content: List[ChatCompletionContentPartParam] = []
451
+ for part in message.parts:
452
+ if isinstance(part, ContextTextPart):
453
+ if part.text.strip():
454
+ content.append(_build_text_content_part(part.text))
455
+ continue
456
+
457
+ normalized_image = _normalize_image_part_for_openai(part)
458
+ if normalized_image is None:
459
+ content.append(_build_text_content_part("[图片内容不可用]"))
460
+ continue
461
+
462
+ image_format, image_base64 = normalized_image
463
+ content.append(
464
+ {
465
+ "type": "image_url",
466
+ "image_url": {
467
+ "url": f"data:image/{image_format};base64,{image_base64}",
468
+ },
469
+ }
470
+ )
471
+ if not content:
472
+ return ""
473
+ return content
474
+
475
+
476
+ def _convert_assistant_tool_calls(
477
+ tool_calls: List[ContextToolCall],
478
+ ) -> List[ChatCompletionMessageFunctionToolCallParam]:
479
+ """将内部工具调用转换为 OpenAI assistant tool_calls 结构。
480
+
481
+ Args:
482
+ tool_calls: 内部工具调用列表。
483
+
484
+ Returns:
485
+ List[ChatCompletionMessageFunctionToolCallParam]: OpenAI 兼容工具调用结构。
486
+ """
487
+ converted_tool_calls: List[ChatCompletionMessageFunctionToolCallParam] = []
488
+ for tool_call in tool_calls:
489
+ converted_tool_calls.append(
490
+ {
491
+ "id": tool_call.call_id,
492
+ "type": "function",
493
+ "function": {
494
+ "name": tool_call.func_name,
495
+ "arguments": json.dumps(tool_call.materialize_args(), ensure_ascii=False),
496
+ },
497
+ }
498
+ )
499
+ return converted_tool_calls
500
+
501
+
502
+ def _sanitize_messages_for_toolless_request(items: List[ContextItem]) -> List[ContextItem]:
503
+ """在无工具请求时清洗历史工具调用链,避免兼容接口拒收消息。"""
504
+ return [item for item in items if not isinstance(item, (FunctionCallItem, FunctionCallOutputItem))]
505
+
506
+
507
+ def _convert_assistant_item_group(items: List[ContextItem]) -> ChatCompletionAssistantMessageParam | None:
508
+ """把相邻的模型输出 Items 折叠为 Chat assistant message。"""
509
+
510
+ text_parts: List[str] = []
511
+ tool_calls: List[ContextToolCall] = []
512
+ for item in items:
513
+ if isinstance(item, AssistantMessageItem):
514
+ for part in item.parts:
515
+ part_text = _get_portable_text_part(part)
516
+ if part_text is None:
517
+ raise ValueError("Chat assistant message 仅支持文本片段")
518
+ text_parts.append(part_text)
519
+ elif isinstance(item, FunctionCallItem):
520
+ tool_calls.append(item.tool_call)
521
+
522
+ content = "".join(text_parts)
523
+ if not content and not tool_calls:
524
+ return None
525
+ payload: ChatCompletionAssistantMessageParam = {
526
+ "role": "assistant",
527
+ "content": content or None,
528
+ }
529
+ if tool_calls:
530
+ payload["tool_calls"] = _convert_assistant_tool_calls(tool_calls)
531
+ return payload
532
+
533
+
534
+ def _convert_messages(items: List[ContextItem]) -> List[ChatCompletionMessageParam]:
535
+ """将内部消息列表转换为 OpenAI 兼容消息列表。
536
+
537
+ Args:
538
+ items: 内部 Context Items。
539
+
540
+ Returns:
541
+ List[ChatCompletionMessageParam]: OpenAI SDK 所需的消息结构列表。
542
+ """
543
+ converted_messages: List[ChatCompletionMessageParam] = []
544
+ index = 0
545
+ while index < len(items):
546
+ item = items[index]
547
+ if isinstance(item, SystemMessageItem):
548
+ system_payload: ChatCompletionSystemMessageParam = {
549
+ "role": "system",
550
+ "content": _convert_text_only_message_content(item),
551
+ }
552
+ converted_messages.append(system_payload)
553
+ index += 1
554
+ continue
555
+
556
+ if isinstance(item, UserMessageItem):
557
+ user_payload: ChatCompletionUserMessageParam = {
558
+ "role": "user",
559
+ "content": _convert_user_message_content(item),
560
+ }
561
+ converted_messages.append(user_payload)
562
+ index += 1
563
+ continue
564
+
565
+ if isinstance(item, FunctionCallOutputItem):
566
+ tool_payload: ChatCompletionToolMessageParam = {
567
+ "role": "tool",
568
+ "content": item.output,
569
+ "tool_call_id": item.call_id,
570
+ }
571
+ converted_messages.append(tool_payload)
572
+ index += 1
573
+ continue
574
+
575
+ if isinstance(
576
+ item,
577
+ (ReasoningItem, AssistantMessageItem, FunctionCallItem, ProviderActivityItem, ProviderOpaqueItem),
578
+ ):
579
+ group_items: List[ContextItem] = [item]
580
+ index += 1
581
+ while index < len(items):
582
+ next_item = items[index]
583
+ if not isinstance(
584
+ next_item,
585
+ (ReasoningItem, AssistantMessageItem, FunctionCallItem, ProviderActivityItem, ProviderOpaqueItem),
586
+ ):
587
+ break
588
+ group_items.append(next_item)
589
+ index += 1
590
+ assistant_payload = _convert_assistant_item_group(group_items)
591
+ if assistant_payload is not None:
592
+ converted_messages.append(assistant_payload)
593
+ continue
594
+
595
+ # ProviderActivityItem 和 ProviderOpaqueItem 没有可移植 Chat 投影。
596
+ index += 1
597
+
598
+ return converted_messages
599
+
600
+
601
+ def _convert_tool_options(tool_options: List[ToolOption]) -> List[ChatCompletionToolParam]:
602
+ """将工具定义转换为 OpenAI 兼容的工具列表。
603
+
604
+ Args:
605
+ tool_options: 内部统一工具定义列表。
606
+
607
+ Returns:
608
+ List[ChatCompletionToolParam]: OpenAI SDK 所需的工具定义列表。
609
+ """
610
+ converted_tools: List[ChatCompletionToolParam] = []
611
+ for tool_option in tool_options:
612
+ parameters_schema = cast(
613
+ Dict[str, object],
614
+ tool_option.parameters_schema or {"type": "object", "properties": {}, "required": []},
615
+ )
616
+ function_schema: FunctionDefinition = {
617
+ "name": tool_option.name,
618
+ "description": tool_option.description,
619
+ "parameters": parameters_schema,
620
+ }
621
+ converted_tools.append(
622
+ {
623
+ "type": "function",
624
+ "function": function_schema,
625
+ }
626
+ )
627
+ return converted_tools
628
+
629
+
630
+ def _extract_usage_record(usage: Any) -> UsageTuple | None:
631
+ """从响应对象中提取 usage 元组,并判断供应商是否上报了 Prompt 缓存用量。
632
+
633
+ Args:
634
+ usage: OpenAI SDK 返回的 usage 对象。
635
+
636
+ Returns:
637
+ UsageTuple | None: `(prompt_tokens, completion_tokens, total_tokens,
638
+ prompt_cache_hit_tokens, prompt_cache_miss_tokens, prompt_cache_reported)`。
639
+ """
640
+ if usage is None:
641
+ return None
642
+ prompt_tokens = getattr(usage, "prompt_tokens", 0) or 0
643
+ # 用 None 哨兵区分「字段缺失」与「字段为 0」:StepFun 等供应商只在命中时返回缓存字段
644
+ reported_hit_tokens = getattr(usage, "prompt_cache_hit_tokens", None)
645
+ reported_miss_tokens = getattr(usage, "prompt_cache_miss_tokens", None)
646
+ prompt_tokens_details = getattr(usage, "prompt_tokens_details", None)
647
+ reported_cached_tokens = (
648
+ getattr(prompt_tokens_details, "cached_tokens", None) if prompt_tokens_details is not None else None
649
+ )
650
+ prompt_cache_reported = (
651
+ reported_hit_tokens is not None or reported_miss_tokens is not None or reported_cached_tokens is not None
652
+ )
653
+ prompt_cache_hit_tokens = reported_hit_tokens or reported_cached_tokens or 0
654
+ prompt_cache_miss_tokens = reported_miss_tokens or 0
655
+ if prompt_cache_miss_tokens == 0 and prompt_cache_hit_tokens > 0:
656
+ prompt_cache_miss_tokens = max(prompt_tokens - prompt_cache_hit_tokens, 0)
657
+ return (
658
+ prompt_tokens,
659
+ getattr(usage, "completion_tokens", 0) or 0,
660
+ getattr(usage, "total_tokens", 0) or 0,
661
+ prompt_cache_hit_tokens,
662
+ prompt_cache_miss_tokens,
663
+ prompt_cache_reported,
664
+ )
665
+
666
+
667
+ def _parse_tool_arguments(
668
+ raw_arguments: str,
669
+ parse_mode: ToolArgumentParseMode,
670
+ response: Any,
671
+ ) -> Dict[str, Any]:
672
+ """解析工具调用参数字符串。
673
+
674
+ Args:
675
+ raw_arguments: 工具调用参数原始字符串。
676
+ parse_mode: 参数解析模式。
677
+ response: 原始响应对象,用于异常上下文。
678
+
679
+ Returns:
680
+ Dict[str, Any]: 解析后的参数字典。
681
+
682
+ Raises:
683
+ RespParseException: 当参数无法解析为字典时抛出。
684
+ """
685
+ if not raw_arguments.strip():
686
+ return {}
687
+
688
+ try:
689
+ if parse_mode == ToolArgumentParseMode.STRICT:
690
+ arguments: Any = json.loads(raw_arguments)
691
+ elif parse_mode == ToolArgumentParseMode.REPAIR:
692
+ arguments = repair_json(raw_arguments, return_objects=True, logging=False)
693
+ else:
694
+ arguments = repair_json(raw_arguments, return_objects=True, logging=False)
695
+ if isinstance(arguments, str) and parse_mode in {
696
+ ToolArgumentParseMode.AUTO,
697
+ ToolArgumentParseMode.DOUBLE_DECODE,
698
+ }:
699
+ arguments = repair_json(arguments, return_objects=True, logging=False)
700
+ except json.JSONDecodeError as exc:
701
+ raise RespParseException(response, f"响应解析失败,无法解析工具调用参数。原始参数:{raw_arguments}") from exc
702
+
703
+ if not isinstance(arguments, dict):
704
+ raise RespParseException(
705
+ response,
706
+ f"响应解析失败,工具调用参数必须解析为字典,实际类型为 {type(arguments).__name__}。",
707
+ )
708
+ return arguments
709
+
710
+
711
+ def _extract_reasoning_and_content(
712
+ content: str,
713
+ parse_mode: ReasoningParseMode,
714
+ ) -> Tuple[str | None, str | None]:
715
+ """从文本内容中提取推理内容与正式输出。
716
+
717
+ Args:
718
+ content: 模型返回的文本内容。
719
+ parse_mode: 推理解析模式。
720
+
721
+ Returns:
722
+ Tuple[str | None, str | None]: `(reasoning_content, content)`。
723
+ """
724
+ if parse_mode in {ReasoningParseMode.NATIVE, ReasoningParseMode.NONE}:
725
+ return None, content
726
+
727
+ match = THINK_CONTENT_PATTERN.match(content)
728
+ if not match:
729
+ return None, content
730
+ if match.group("think") is not None:
731
+ reasoning_content = match.group("think").strip() or None
732
+ final_content = match.group("content").strip() or None
733
+ return reasoning_content, final_content
734
+ if match.group("think_unclosed") is not None:
735
+ return match.group("think_unclosed").strip() or None, None
736
+ return None, match.group("content_only").strip() or None
737
+
738
+
739
+ def _extract_xml_tool_calls(
740
+ raw_text: str | None,
741
+ parse_mode: ToolArgumentParseMode,
742
+ response: Any,
743
+ *,
744
+ source: str,
745
+ ) -> Tuple[str | None, List[ToolCall] | None]:
746
+ """从 XML 风格文本中兜底提取工具调用。"""
747
+ if not isinstance(raw_text, str) or not raw_text.strip():
748
+ return raw_text, None
749
+
750
+ tool_calls: List[ToolCall] = []
751
+
752
+ def _coerce_xml_parameter_value(raw_value: str) -> Any:
753
+ normalized_value = raw_value.strip()
754
+ if not normalized_value:
755
+ return ""
756
+ lowered_value = normalized_value.lower()
757
+ if lowered_value == "true":
758
+ return True
759
+ if lowered_value == "false":
760
+ return False
761
+ if lowered_value in {"null", "none"}:
762
+ return None
763
+ if normalized_value.startswith(("{", "[")):
764
+ try:
765
+ return repair_json(normalized_value, return_objects=True, logging=False)
766
+ except Exception:
767
+ return normalized_value
768
+ return normalized_value
769
+
770
+ def _parse_xml_parameters(raw_arguments: str) -> Dict[str, Any] | None:
771
+ parameters = {
772
+ match.group("name").strip(): _coerce_xml_parameter_value(match.group("value"))
773
+ for match in XML_PARAMETER_PATTERN.finditer(raw_arguments)
774
+ }
775
+ return parameters or None
776
+
777
+ def _replace_tool_call(match: re.Match[str]) -> str:
778
+ body = match.group("body")
779
+ function_match = XML_FUNCTION_CALL_PATTERN.search(body)
780
+ if function_match is None:
781
+ return match.group(0)
782
+
783
+ function_name = function_match.group("name").strip()
784
+ raw_arguments = function_match.group("arguments").strip()
785
+ arguments = _parse_xml_parameters(raw_arguments)
786
+ if arguments is None:
787
+ arguments = _parse_tool_arguments(raw_arguments, parse_mode, response) if raw_arguments else {}
788
+ tool_calls.append(
789
+ ToolCall(
790
+ call_id=_build_fallback_tool_call_id("xml_tool_call"),
791
+ func_name=function_name,
792
+ args=arguments,
793
+ extra_content=_build_tool_call_extra_content(source),
794
+ )
795
+ )
796
+ return ""
797
+
798
+ cleaned_text = XML_TOOL_CALL_PATTERN.sub(_replace_tool_call, raw_text).strip() or None
799
+ return cleaned_text, tool_calls or None
800
+
801
+
802
+ def _get_content_block_value(content_block: Any, key: str) -> Any:
803
+ """读取 OpenAI 兼容 content block 的字段,兼容 dict 与 SDK 对象。"""
804
+
805
+ if isinstance(content_block, dict):
806
+ return content_block.get(key)
807
+ return getattr(content_block, key, None)
808
+
809
+
810
+ def _extract_content_block_text(content_block: Any) -> str:
811
+ """从 content block 中提取文本字段。"""
812
+
813
+ for key in ("text", "content", "reasoning", "summary"):
814
+ value = _get_content_block_value(content_block, key)
815
+ if isinstance(value, str) and value.strip():
816
+ return value
817
+ return ""
818
+
819
+
820
+ def _normalize_content_block_arguments(
821
+ raw_arguments: Any, parse_mode: ToolArgumentParseMode, response: Any
822
+ ) -> Dict[str, Any]:
823
+ """将 content block 内的工具参数规范化为字典。"""
824
+
825
+ if raw_arguments is None:
826
+ return {}
827
+ if isinstance(raw_arguments, dict):
828
+ return raw_arguments
829
+ if isinstance(raw_arguments, str):
830
+ normalized_arguments = raw_arguments.strip()
831
+ if not normalized_arguments:
832
+ return {}
833
+ return _parse_tool_arguments(normalized_arguments, parse_mode, response)
834
+ return {"value": raw_arguments}
835
+
836
+
837
+ def _extract_content_block_tool_call(
838
+ content_block: Any,
839
+ parse_mode: ToolArgumentParseMode,
840
+ response: Any,
841
+ *,
842
+ source: str,
843
+ ) -> ToolCall | None:
844
+ """从 content block 中提取工具调用。"""
845
+
846
+ block_type = str(_get_content_block_value(content_block, "type") or "").strip().lower()
847
+ if block_type not in {"tool_use", "tool_call", "function_call"}:
848
+ return None
849
+
850
+ function_info = _get_content_block_value(content_block, "function")
851
+ if function_info is not None:
852
+ call_name = _get_content_block_value(function_info, "name")
853
+ raw_arguments = _get_content_block_value(function_info, "arguments")
854
+ else:
855
+ call_name = _get_content_block_value(content_block, "name") or _get_content_block_value(
856
+ content_block, "func_name"
857
+ )
858
+ raw_arguments = (
859
+ _get_content_block_value(content_block, "input")
860
+ or _get_content_block_value(content_block, "arguments")
861
+ or _get_content_block_value(content_block, "args")
862
+ )
863
+
864
+ if not isinstance(call_name, str) or not call_name.strip():
865
+ raise RespParseException(response, "响应解析失败,content block 工具调用缺少 name 字段。")
866
+
867
+ call_id = (
868
+ _get_content_block_value(content_block, "id")
869
+ or _get_content_block_value(content_block, "tool_call_id")
870
+ or _get_content_block_value(content_block, "call_id")
871
+ or _build_fallback_tool_call_id("content_block_tool_call")
872
+ )
873
+ return ToolCall(
874
+ call_id=str(call_id),
875
+ func_name=call_name.strip(),
876
+ args=_normalize_content_block_arguments(raw_arguments, parse_mode, response),
877
+ extra_content=_build_tool_call_extra_content(source),
878
+ )
879
+
880
+
881
+ def _extract_content_blocks(
882
+ message_content: Any,
883
+ parse_mode: ToolArgumentParseMode,
884
+ response: Any,
885
+ ) -> tuple[str | None, str | None, List[ToolCall] | None]:
886
+ """解析同时包含 reasoning/text/tool_use 的 content block 响应。"""
887
+
888
+ if not isinstance(message_content, list):
889
+ return None, None, None
890
+
891
+ reasoning_parts: List[str] = []
892
+ content_parts: List[str] = []
893
+ tool_calls: List[ToolCall] = []
894
+ for content_block in message_content:
895
+ block_type = str(_get_content_block_value(content_block, "type") or "").strip().lower()
896
+ if block_type in {"reasoning", "reasoning_content", "thinking", "thought"}:
897
+ block_text = _extract_content_block_text(content_block).strip()
898
+ if block_text:
899
+ reasoning_parts.append(block_text)
900
+ continue
901
+
902
+ tool_call = _extract_content_block_tool_call(
903
+ content_block,
904
+ parse_mode,
905
+ response,
906
+ source=TOOL_CALL_SOURCE_RESPONSE,
907
+ )
908
+ if tool_call is not None:
909
+ tool_calls.append(tool_call)
910
+ continue
911
+
912
+ if block_type in {"text", "output_text", "message"}:
913
+ block_text = _extract_content_block_text(content_block).strip()
914
+ if block_text:
915
+ content_parts.append(block_text)
916
+
917
+ tool_call_source = (
918
+ TOOL_CALL_SOURCE_REASONING if reasoning_parts and not content_parts else TOOL_CALL_SOURCE_RESPONSE
919
+ )
920
+ tool_calls = [_set_tool_call_source(tool_call, tool_call_source) for tool_call in tool_calls]
921
+ reasoning_content = "\n".join(reasoning_parts).strip() or None
922
+ content = "\n".join(content_parts).strip() or None
923
+ return reasoning_content, content, tool_calls or None
924
+
925
+
926
+ def _log_length_truncation(
927
+ finish_reason: str | None,
928
+ model_name: str | None,
929
+ max_tokens: int | None,
930
+ trace_context: RequestTraceContext | None,
931
+ ) -> None:
932
+ """记录因长度截断导致的告警日志。"""
933
+ if finish_reason == "length":
934
+ task_name = trace_context.task_name if trace_context else "未知"
935
+ request_type = trace_context.request_type if trace_context else "未知"
936
+ limit = str(max_tokens) if max_tokens is not None else "未指定(由服务商决定)"
937
+ logger.info(
938
+ f"模型{model_name or ''}因为达到最大输出 token 限制(max_tokens={limit},"
939
+ f"任务={task_name},请求类型={request_type}),可能仅输出部分内容,可视情况调整"
940
+ )
941
+
942
+
943
+ def _apply_xml_tool_call_fallback(
944
+ content: str | None,
945
+ reasoning_content: str | None,
946
+ tool_calls: List[ToolCall] | None,
947
+ parse_mode: ToolArgumentParseMode,
948
+ raw_response: Any,
949
+ ) -> Tuple[str | None, str | None, List[ToolCall] | None]:
950
+ """当上游未返回标准 tool_calls 时,尝试从 XML 文本兜底解析。
951
+
952
+ 这是一个暂时性处理方法,用来兼容思维链中混入工具调用的返回格式,
953
+ 后续可能随着模型或上游接口的规范化而变更。
954
+ """
955
+ if tool_calls:
956
+ return content, reasoning_content, tool_calls
957
+
958
+ cleaned_reasoning, extracted_tool_calls = _extract_xml_tool_calls(
959
+ reasoning_content,
960
+ parse_mode,
961
+ raw_response,
962
+ source=TOOL_CALL_SOURCE_REASONING,
963
+ )
964
+ if extracted_tool_calls:
965
+ if not content and cleaned_reasoning:
966
+ content = cleaned_reasoning
967
+ cleaned_reasoning = None
968
+ logger.warning("OpenAI 兼容响应未返回标准 tool_calls,已从 XML 文本兜底解析工具调用")
969
+ return content, cleaned_reasoning, extracted_tool_calls
970
+
971
+ cleaned_content, extracted_tool_calls = _extract_xml_tool_calls(
972
+ content,
973
+ parse_mode,
974
+ raw_response,
975
+ source=TOOL_CALL_SOURCE_RESPONSE,
976
+ )
977
+ if extracted_tool_calls:
978
+ logger.warning("OpenAI 兼容响应未返回标准 tool_calls,已从 XML 文本兜底解析工具调用")
979
+ return cleaned_content, cleaned_reasoning, extracted_tool_calls
980
+
981
+
982
+ def _build_portable_api_response(
983
+ *,
984
+ content: str | None,
985
+ reasoning_content: str | None,
986
+ tool_calls: List[ToolCall] | None,
987
+ logical_turn_id: str,
988
+ raw_data: Any,
989
+ ) -> APIResponse:
990
+ """将 Chat Completions 的复合输出冻结为通用 Context Items。"""
991
+
992
+ context_tool_calls = [
993
+ ContextToolCall.create(
994
+ call_id=tool_call.call_id,
995
+ func_name=tool_call.func_name,
996
+ args=tool_call.args,
997
+ extra_content=tool_call.extra_content,
998
+ )
999
+ for tool_call in (tool_calls or [])
1000
+ ]
1001
+ return APIResponse(
1002
+ output_items=build_portable_output_items(
1003
+ content=content,
1004
+ reasoning=reasoning_content,
1005
+ tool_calls=context_tool_calls,
1006
+ logical_turn_id=logical_turn_id,
1007
+ ),
1008
+ raw_data=raw_data,
1009
+ )
1010
+
1011
+
1012
+ def _coerce_openai_argument(value: Any) -> Any | Omit:
1013
+ """将可选参数转换为 OpenAI SDK 期望的值。
1014
+
1015
+ Args:
1016
+ value: 原始参数值。
1017
+
1018
+ Returns:
1019
+ Any | Omit: `None` 会被转换为 `omit`,其余值原样返回。
1020
+ """
1021
+ if value is None:
1022
+ return omit
1023
+ return value
1024
+
1025
+
1026
+ def _snapshot_openai_argument(value: Any | Omit) -> Any | None:
1027
+ """将 OpenAI SDK 参数转换为适合写入快照的普通值。"""
1028
+ if value is omit:
1029
+ return None
1030
+ return value
1031
+
1032
+
1033
+ def _build_api_status_message(error: APIStatusError) -> str:
1034
+ """构建更适合记录和展示的状态错误信息。
1035
+
1036
+ Args:
1037
+ error: OpenAI SDK 抛出的状态错误。
1038
+
1039
+ Returns:
1040
+ str: 拼装后的错误信息。
1041
+ """
1042
+ message_parts: List[str] = []
1043
+ if getattr(error, "message", None):
1044
+ message_parts.append(str(error.message))
1045
+ response_text = getattr(getattr(error, "response", None), "text", None)
1046
+ if response_text:
1047
+ message_parts.append(str(response_text))
1048
+ if message_parts:
1049
+ return " | ".join(message_parts)
1050
+ return f"上游接口返回状态码 {error.status_code}"
1051
+
1052
+
1053
+ def _is_max_tokens_unsupported_error(error: APIStatusError) -> bool:
1054
+ """判断该 400 错误是否为「max_tokens 不被支持、应改用 max_completion_tokens」。
1055
+
1056
+ 以错误原文匹配为主判据(OpenAI 的报错即 "Use 'max_completion_tokens' instead.")。
1057
+ 错误正文可能落在 error.message,也可能仅落在 response.text(如经代理/网关,或 SDK
1058
+ 未解析到标准 error 结构时),故复用 _build_api_status_message 同时覆盖两者。
1059
+
1060
+ Args:
1061
+ error: OpenAI SDK 抛出的状态错误。
1062
+
1063
+ Returns:
1064
+ bool: 命中该特征返回 True。
1065
+ """
1066
+ if error.status_code != 400:
1067
+ return False
1068
+ return "max_completion_tokens" in _build_api_status_message(error).lower()
1069
+
1070
+
1071
+ @dataclass(slots=True)
1072
+ class _StreamedToolCallState:
1073
+ """流式工具调用累积状态。"""
1074
+
1075
+ index: int
1076
+ call_id: str = ""
1077
+ function_name: str = ""
1078
+ arguments_buffer: io.StringIO = field(default_factory=io.StringIO)
1079
+
1080
+ def append_arguments(self, arguments_chunk: str) -> None:
1081
+ """追加一段工具调用参数字符串。
1082
+
1083
+ Args:
1084
+ arguments_chunk: 参数增量片段。
1085
+ """
1086
+ self.arguments_buffer.write(arguments_chunk)
1087
+
1088
+ def close(self) -> None:
1089
+ """关闭内部缓存。"""
1090
+ if not self.arguments_buffer.closed:
1091
+ self.arguments_buffer.close()
1092
+
1093
+
1094
+ class _OpenAIStreamAccumulator:
1095
+ """OpenAI 兼容流式响应累积器。"""
1096
+
1097
+ def __init__(
1098
+ self,
1099
+ reasoning_parse_mode: ReasoningParseMode,
1100
+ tool_argument_parse_mode: ToolArgumentParseMode,
1101
+ reasoning_key: str,
1102
+ logical_turn_id: str,
1103
+ ) -> None:
1104
+ """初始化累积器。
1105
+
1106
+ Args:
1107
+ reasoning_parse_mode: 推理内容解析模式。
1108
+ tool_argument_parse_mode: 工具参数解析模式。
1109
+ reasoning_key: 允许读取的原生推理字段名。
1110
+ """
1111
+ self.reasoning_parse_mode = reasoning_parse_mode
1112
+ self.tool_argument_parse_mode = tool_argument_parse_mode
1113
+ self.reasoning_key = reasoning_key
1114
+ self.logical_turn_id = logical_turn_id
1115
+ self.reasoning_buffer = io.StringIO()
1116
+ self.content_buffer = io.StringIO()
1117
+ self.tool_call_states: Dict[int, _StreamedToolCallState] = {}
1118
+ self.finish_reason: str | None = None
1119
+ self.model_name: str | None = None
1120
+ self._using_native_reasoning = False
1121
+
1122
+ def capture_event_metadata(self, event: ChatCompletionChunk) -> None:
1123
+ """捕获事件中的完成原因和模型名。
1124
+
1125
+ Args:
1126
+ event: 当前流式事件。
1127
+ """
1128
+ if getattr(event, "model", None) and not self.model_name:
1129
+ self.model_name = event.model
1130
+ if getattr(event, "choices", None):
1131
+ finish_reason = getattr(event.choices[0], "finish_reason", None)
1132
+ if finish_reason:
1133
+ self.finish_reason = finish_reason
1134
+
1135
+ def process_delta(self, delta: ChoiceDelta) -> None:
1136
+ """处理一个增量块。
1137
+
1138
+ Args:
1139
+ delta: 当前增量对象。
1140
+ """
1141
+ self._process_reasoning_delta(delta)
1142
+ self._process_tool_call_delta(delta)
1143
+
1144
+ def _process_reasoning_delta(self, delta: ChoiceDelta) -> None:
1145
+ """处理推理内容与正式内容。
1146
+
1147
+ Args:
1148
+ delta: 当前增量对象。
1149
+ """
1150
+ native_reasoning = _extract_reasoning_content(delta, self.reasoning_key)
1151
+ if native_reasoning is not None:
1152
+ self._using_native_reasoning = True
1153
+ if self.reasoning_parse_mode != ReasoningParseMode.NONE:
1154
+ self.reasoning_buffer.write(native_reasoning)
1155
+ return
1156
+
1157
+ content_chunk = getattr(delta, "content", None)
1158
+ if not isinstance(content_chunk, str) or content_chunk == "":
1159
+ return
1160
+
1161
+ if self.reasoning_parse_mode == ReasoningParseMode.NONE:
1162
+ self.content_buffer.write(content_chunk)
1163
+ return
1164
+
1165
+ if self.reasoning_parse_mode == ReasoningParseMode.NATIVE:
1166
+ self.content_buffer.write(content_chunk)
1167
+ return
1168
+
1169
+ self.content_buffer.write(content_chunk)
1170
+
1171
+ def _process_tool_call_delta(self, delta: ChoiceDelta) -> None:
1172
+ """处理工具调用增量。
1173
+
1174
+ Args:
1175
+ delta: 当前增量对象。
1176
+ """
1177
+ tool_call_deltas = getattr(delta, "tool_calls", None) or []
1178
+ for tool_call_delta in tool_call_deltas:
1179
+ state = self.tool_call_states.setdefault(
1180
+ tool_call_delta.index, _StreamedToolCallState(index=tool_call_delta.index)
1181
+ )
1182
+ if tool_call_delta.id:
1183
+ state.call_id = tool_call_delta.id
1184
+ function = tool_call_delta.function
1185
+ if function is not None and function.name:
1186
+ state.function_name = function.name
1187
+ if function is not None and function.arguments:
1188
+ state.append_arguments(function.arguments)
1189
+
1190
+ def build_response(self) -> APIResponse:
1191
+ """构建最终 APIResponse 对象。
1192
+
1193
+ Returns:
1194
+ APIResponse: 累积完成的响应对象。
1195
+
1196
+ Raises:
1197
+ EmptyResponseException: 当响应中既无正文、推理内容也无工具调用时抛出。
1198
+ RespParseException: 当工具调用结构不完整时抛出。
1199
+ """
1200
+ content = self.content_buffer.getvalue().strip()
1201
+ reasoning_content = self.reasoning_buffer.getvalue().strip()
1202
+ if not self._using_native_reasoning and self.reasoning_parse_mode != ReasoningParseMode.NONE and content:
1203
+ parsed_reasoning_content, parsed_content = _extract_reasoning_and_content(
1204
+ content=content,
1205
+ parse_mode=self.reasoning_parse_mode,
1206
+ )
1207
+ if parsed_reasoning_content:
1208
+ reasoning_content = parsed_reasoning_content
1209
+ content = parsed_content or ""
1210
+ tool_calls: List[ToolCall] = []
1211
+ if self.tool_call_states:
1212
+ for index in sorted(self.tool_call_states):
1213
+ state = self.tool_call_states[index]
1214
+ if not state.function_name:
1215
+ raise RespParseException(None, f"响应解析失败,工具调用 {index} 缺少函数名。")
1216
+ raw_arguments = state.arguments_buffer.getvalue().strip()
1217
+ arguments = (
1218
+ _parse_tool_arguments(raw_arguments, self.tool_argument_parse_mode, None) if raw_arguments else None
1219
+ )
1220
+ call_id = state.call_id or _build_fallback_tool_call_id(f"tool_call_{index}")
1221
+ source = TOOL_CALL_SOURCE_REASONING if reasoning_content else TOOL_CALL_SOURCE_RESPONSE
1222
+ tool_calls.append(
1223
+ ToolCall(
1224
+ call_id=call_id,
1225
+ func_name=state.function_name,
1226
+ args=arguments,
1227
+ extra_content=_build_tool_call_extra_content(source),
1228
+ )
1229
+ )
1230
+
1231
+ raw_data = {"model": self.model_name} if self.model_name else None
1232
+ content, reasoning_content, resolved_tool_calls = _apply_xml_tool_call_fallback(
1233
+ content or None,
1234
+ reasoning_content or None,
1235
+ tool_calls or None,
1236
+ self.tool_argument_parse_mode,
1237
+ raw_data,
1238
+ )
1239
+ if not content and not reasoning_content and not resolved_tool_calls:
1240
+ raise EmptyResponseException(raw_data)
1241
+ return _build_portable_api_response(
1242
+ content=content,
1243
+ reasoning_content=reasoning_content,
1244
+ tool_calls=resolved_tool_calls,
1245
+ logical_turn_id=self.logical_turn_id,
1246
+ raw_data=raw_data,
1247
+ )
1248
+
1249
+ def close(self) -> None:
1250
+ """关闭内部缓冲区。"""
1251
+ if not self.reasoning_buffer.closed:
1252
+ self.reasoning_buffer.close()
1253
+ if not self.content_buffer.closed:
1254
+ self.content_buffer.close()
1255
+ for state in self.tool_call_states.values():
1256
+ state.close()
1257
+
1258
+ def snapshot_interrupted_output(self) -> InterruptedStreamOutput:
1259
+ """提取原始片段,避免用完整响应解析器解析半截工具参数。"""
1260
+
1261
+ return InterruptedStreamOutput(
1262
+ reasoning=self.reasoning_buffer.getvalue(),
1263
+ content=self.content_buffer.getvalue(),
1264
+ tool_calls=[
1265
+ {"name": state.function_name or "尚未收到名称", "arguments": state.arguments_buffer.getvalue()}
1266
+ for _, state in sorted(self.tool_call_states.items())
1267
+ ],
1268
+ model_name=self.model_name or "",
1269
+ )
1270
+
1271
+
1272
+ async def _default_stream_response_handler(
1273
+ resp_stream: AsyncStream[ChatCompletionChunk],
1274
+ interrupt_flag: asyncio.Event | None,
1275
+ *,
1276
+ reasoning_parse_mode: ReasoningParseMode,
1277
+ tool_argument_parse_mode: ToolArgumentParseMode,
1278
+ reasoning_key: str,
1279
+ logical_turn_id: str,
1280
+ max_tokens: int | None,
1281
+ trace_context: RequestTraceContext | None,
1282
+ ) -> Tuple[APIResponse, UsageTuple | None]:
1283
+ """处理 OpenAI 兼容流式响应。
1284
+
1285
+ Args:
1286
+ resp_stream: OpenAI SDK 返回的流式响应对象。
1287
+ interrupt_flag: 外部中断标记。
1288
+ reasoning_parse_mode: 推理内容解析模式。
1289
+ tool_argument_parse_mode: 工具参数解析模式。
1290
+ reasoning_key: 允许读取的原生推理字段名。
1291
+
1292
+ Returns:
1293
+ Tuple[APIResponse, UsageTuple | None]: 解析后的响应与 usage 统计。
1294
+ """
1295
+ accumulator = _OpenAIStreamAccumulator(
1296
+ reasoning_parse_mode=reasoning_parse_mode,
1297
+ tool_argument_parse_mode=tool_argument_parse_mode,
1298
+ reasoning_key=reasoning_key,
1299
+ logical_turn_id=logical_turn_id,
1300
+ )
1301
+ usage_record: UsageTuple | None = None
1302
+
1303
+ try:
1304
+ async for event in resp_stream:
1305
+ if interrupt_flag and interrupt_flag.is_set():
1306
+ raise ReqAbortException("请求被外部信号中断")
1307
+
1308
+ accumulator.capture_event_metadata(event)
1309
+ event_usage = _extract_usage_record(getattr(event, "usage", None))
1310
+ if event_usage is not None:
1311
+ usage_record = event_usage
1312
+
1313
+ if not getattr(event, "choices", None):
1314
+ continue
1315
+
1316
+ accumulator.process_delta(event.choices[0].delta)
1317
+
1318
+ response = accumulator.build_response()
1319
+ model_name = None
1320
+ if isinstance(response.raw_data, dict):
1321
+ model_name = response.raw_data.get("model")
1322
+ _log_length_truncation(accumulator.finish_reason, model_name, max_tokens, trace_context)
1323
+ return response, usage_record
1324
+ except (asyncio.CancelledError, ReqAbortException) as exc:
1325
+ if interrupt_flag is not None and interrupt_flag.is_set():
1326
+ raise ReqAbortException(
1327
+ "请求被外部信号中断", partial_output=accumulator.snapshot_interrupted_output()
1328
+ ) from exc
1329
+ raise
1330
+ finally:
1331
+ accumulator.close()
1332
+
1333
+
1334
+ def _default_normal_response_parser(
1335
+ resp: ChatCompletion,
1336
+ *,
1337
+ reasoning_parse_mode: ReasoningParseMode,
1338
+ tool_argument_parse_mode: ToolArgumentParseMode,
1339
+ reasoning_key: str,
1340
+ logical_turn_id: str,
1341
+ max_tokens: int | None,
1342
+ trace_context: RequestTraceContext | None,
1343
+ ) -> Tuple[APIResponse, UsageTuple | None]:
1344
+ """解析 OpenAI 兼容的非流式响应。
1345
+
1346
+ Args:
1347
+ resp: OpenAI SDK 返回的聊天补全响应。
1348
+ reasoning_parse_mode: 推理内容解析模式。
1349
+ tool_argument_parse_mode: 工具参数解析模式。
1350
+ reasoning_key: 允许读取的原生推理字段名。
1351
+
1352
+ Returns:
1353
+ Tuple[APIResponse, UsageTuple | None]: 解析后的响应与 usage 统计。
1354
+
1355
+ Raises:
1356
+ EmptyResponseException: 当 choices 为空,或响应中既无正文、推理内容也无工具调用时抛出。
1357
+ """
1358
+ choices = getattr(resp, "choices", None)
1359
+ if not choices:
1360
+ raise EmptyResponseException(resp, "响应解析失败,choices 为空或缺失")
1361
+
1362
+ message_part = choices[0].message
1363
+ native_reasoning = _extract_reasoning_content(message_part, reasoning_key)
1364
+ raw_message_content = message_part.content
1365
+ message_content = raw_message_content if isinstance(raw_message_content, str) else None
1366
+ content_block_reasoning, content_block_content, content_block_tool_calls = _extract_content_blocks(
1367
+ raw_message_content,
1368
+ tool_argument_parse_mode,
1369
+ resp,
1370
+ )
1371
+
1372
+ if native_reasoning is not None and reasoning_parse_mode != ReasoningParseMode.NONE:
1373
+ reasoning_content = native_reasoning
1374
+ content = message_content or content_block_content
1375
+ elif isinstance(message_content, str) and message_content:
1376
+ reasoning_content, content = _extract_reasoning_and_content(
1377
+ content=message_content,
1378
+ parse_mode=reasoning_parse_mode,
1379
+ )
1380
+ else:
1381
+ reasoning_content = content_block_reasoning
1382
+ content = content_block_content
1383
+
1384
+ parsed_tool_calls: List[ToolCall] = []
1385
+ tool_calls = getattr(message_part, "tool_calls", None) or []
1386
+ if tool_calls:
1387
+ for tool_call in tool_calls:
1388
+ if tool_call.type != "function":
1389
+ raise RespParseException(resp, f"响应解析失败,暂不支持工具调用类型 {tool_call.type}。")
1390
+ raw_arguments = tool_call.function.arguments or ""
1391
+ arguments = _parse_tool_arguments(raw_arguments, tool_argument_parse_mode, resp)
1392
+ parsed_tool_calls.append(
1393
+ ToolCall(
1394
+ call_id=tool_call.id,
1395
+ func_name=tool_call.function.name,
1396
+ args=arguments,
1397
+ extra_content=_build_tool_call_extra_content(
1398
+ TOOL_CALL_SOURCE_REASONING if reasoning_content else TOOL_CALL_SOURCE_RESPONSE
1399
+ ),
1400
+ )
1401
+ )
1402
+ elif content_block_tool_calls:
1403
+ parsed_tool_calls = content_block_tool_calls
1404
+
1405
+ usage_record = _extract_usage_record(getattr(resp, "usage", None))
1406
+
1407
+ finish_reason = getattr(resp.choices[0], "finish_reason", None)
1408
+ _log_length_truncation(finish_reason, getattr(resp, "model", None), max_tokens, trace_context)
1409
+ content, reasoning_content, resolved_tool_calls = _apply_xml_tool_call_fallback(
1410
+ content,
1411
+ reasoning_content,
1412
+ parsed_tool_calls or None,
1413
+ tool_argument_parse_mode,
1414
+ resp,
1415
+ )
1416
+ if not content and not reasoning_content and not resolved_tool_calls:
1417
+ raise EmptyResponseException(resp)
1418
+ return (
1419
+ _build_portable_api_response(
1420
+ content=content,
1421
+ reasoning_content=reasoning_content,
1422
+ tool_calls=resolved_tool_calls,
1423
+ logical_turn_id=logical_turn_id,
1424
+ raw_data=resp,
1425
+ ),
1426
+ usage_record,
1427
+ )
1428
+
1429
+
1430
+ @client_registry.register_client_class("openai")
1431
+ class OpenaiClient(AdapterClient[AsyncStream[ChatCompletionChunk], ChatCompletion]):
1432
+ """OpenAI 兼容客户端。"""
1433
+
1434
+ client: AsyncOpenAI
1435
+ reasoning_parse_mode: ReasoningParseMode
1436
+ reasoning_key: str
1437
+ tool_argument_parse_mode: ToolArgumentParseMode
1438
+
1439
+ def __init__(self, api_provider: APIProvider) -> None:
1440
+ """初始化 OpenAI 兼容客户端。
1441
+
1442
+ Args:
1443
+ api_provider: API 提供商配置。
1444
+ """
1445
+ super().__init__(api_provider)
1446
+ client_config = build_openai_compatible_client_config(api_provider)
1447
+ self.reasoning_parse_mode = _normalize_reasoning_parse_mode(api_provider.reasoning_parse_mode)
1448
+ self.reasoning_key = _build_reasoning_key(api_provider)
1449
+ self.tool_argument_parse_mode = _normalize_tool_argument_parse_mode(api_provider.tool_argument_parse_mode)
1450
+ default_headers = _canonicalize_authorization_header(client_config.default_headers)
1451
+ # api_key 为空表示鉴权不交给 SDK(none/header/query 或自定义 Bearer 前缀);
1452
+ # 此时若没有自定义 Authorization 头覆盖占位密钥,每次请求都要显式省略它
1453
+ self._omit_sdk_authorization = not client_config.api_key and _AUTHORIZATION_HEADER not in default_headers
1454
+ self.client = AsyncOpenAI(
1455
+ api_key=client_config.api_key or _SDK_AUTH_PLACEHOLDER_API_KEY,
1456
+ organization=api_provider.organization,
1457
+ project=api_provider.project,
1458
+ base_url=client_config.base_url,
1459
+ timeout=api_provider.timeout,
1460
+ # 重试统一由 LLMOrchestrator 外层循环按 api_provider.max_retry 负责(计数、日志、retry_interval);
1461
+ # SDK 内部再重试会与外层嵌套,使超时请求被重复发送并重复计费输出 token(#1769)
1462
+ max_retries=0,
1463
+ default_headers=default_headers or None,
1464
+ default_query=client_config.default_query or None,
1465
+ )
1466
+
1467
+ def _build_request_headers(self, extra_headers: Dict[str, str]) -> Dict[str, str | Omit] | None:
1468
+ """构造单次请求的附加请求头。
1469
+
1470
+ Args:
1471
+ extra_headers: 模型 `extra_params` 中声明的附加请求头。
1472
+
1473
+ Returns:
1474
+ Dict[str, str | Omit] | None: 传给 SDK 的 `extra_headers`;鉴权不交给 SDK 时
1475
+ 会显式省略占位密钥生成的 `Authorization` 头。
1476
+ """
1477
+ request_headers: Dict[str, str | Omit] = dict(_canonicalize_authorization_header(extra_headers))
1478
+ if self._omit_sdk_authorization:
1479
+ request_headers.setdefault(_AUTHORIZATION_HEADER, omit)
1480
+ return request_headers or None
1481
+
1482
+ def _build_default_stream_response_handler(
1483
+ self,
1484
+ request: ResponseRequest,
1485
+ ) -> ProviderStreamResponseHandler[AsyncStream[ChatCompletionChunk]]:
1486
+ """构建 OpenAI 默认流式响应处理器。
1487
+
1488
+ Args:
1489
+ request: 统一响应请求对象。
1490
+
1491
+ Returns:
1492
+ ProviderStreamResponseHandler[AsyncStream[ChatCompletionChunk]]: 默认流式处理器。
1493
+ """
1494
+ async def default_stream_handler(
1495
+ resp_stream: AsyncStream[ChatCompletionChunk],
1496
+ flag: asyncio.Event | None,
1497
+ ) -> Tuple[APIResponse, UsageTuple | None]:
1498
+ """包装默认流式解析器。"""
1499
+ return await _default_stream_response_handler(
1500
+ resp_stream,
1501
+ flag,
1502
+ reasoning_parse_mode=self.reasoning_parse_mode,
1503
+ tool_argument_parse_mode=self.tool_argument_parse_mode,
1504
+ reasoning_key=self.reasoning_key,
1505
+ logical_turn_id=request.logical_turn_id,
1506
+ max_tokens=request.max_tokens,
1507
+ trace_context=request.trace_context,
1508
+ )
1509
+
1510
+ return default_stream_handler
1511
+
1512
+ def _build_default_response_parser(
1513
+ self,
1514
+ request: ResponseRequest,
1515
+ ) -> ProviderResponseParser[ChatCompletion]:
1516
+ """构建 OpenAI 默认非流式响应解析器。
1517
+
1518
+ Args:
1519
+ request: 统一响应请求对象。
1520
+
1521
+ Returns:
1522
+ ProviderResponseParser[ChatCompletion]: 默认非流式解析器。
1523
+ """
1524
+ def default_response_parser(
1525
+ response: ChatCompletion,
1526
+ ) -> Tuple[APIResponse, UsageTuple | None]:
1527
+ """包装默认非流式解析器。"""
1528
+ return _default_normal_response_parser(
1529
+ response,
1530
+ reasoning_parse_mode=self.reasoning_parse_mode,
1531
+ tool_argument_parse_mode=self.tool_argument_parse_mode,
1532
+ reasoning_key=self.reasoning_key,
1533
+ logical_turn_id=request.logical_turn_id,
1534
+ max_tokens=request.max_tokens,
1535
+ trace_context=request.trace_context,
1536
+ )
1537
+
1538
+ return default_response_parser
1539
+
1540
+ async def _execute_response_request(
1541
+ self,
1542
+ request: ResponseRequest,
1543
+ stream_response_handler: ProviderStreamResponseHandler[AsyncStream[ChatCompletionChunk]],
1544
+ response_parser: ProviderResponseParser[ChatCompletion],
1545
+ ) -> Tuple[APIResponse, UsageTuple | None]:
1546
+ """执行 OpenAI 兼容的文本/多模态响应请求。
1547
+
1548
+ Args:
1549
+ request: 统一响应请求对象。
1550
+ stream_response_handler: 流式响应处理器。
1551
+ response_parser: 非流式响应解析器。
1552
+
1553
+ Returns:
1554
+ Tuple[APIResponse, UsageTuple | None]: 统一响应对象与可选使用量信息。
1555
+ """
1556
+ snapshot_provider_request = {
1557
+ "base_url": self.api_provider.base_url,
1558
+ "endpoint": "/chat/completions",
1559
+ "method": "POST",
1560
+ "operation": "chat.completions.create",
1561
+ "organization": self.api_provider.organization,
1562
+ "project": self.api_provider.project,
1563
+ "request_kwargs": {},
1564
+ }
1565
+ model_info = request.model_info
1566
+
1567
+ try:
1568
+ request_messages = (
1569
+ list(request.context_items)
1570
+ if request.tool_options
1571
+ else _sanitize_messages_for_toolless_request(request.context_items)
1572
+ )
1573
+ messages_payload: List[ChatCompletionMessageParam] = _convert_messages(request_messages)
1574
+ tools_payload: List[ChatCompletionToolParam] | None = (
1575
+ _convert_tool_options(request.tool_options) if request.tool_options else None
1576
+ )
1577
+ openai_response_format = _convert_response_format(request.response_format)
1578
+ request_overrides = split_openai_request_overrides(
1579
+ request.extra_params,
1580
+ reserved_body_keys=CHAT_COMPLETIONS_RESERVED_EXTRA_BODY_KEYS,
1581
+ )
1582
+
1583
+ temperature_argument = (
1584
+ omit if "temperature" in request_overrides.extra_body else _coerce_openai_argument(request.temperature)
1585
+ )
1586
+
1587
+ async def _dispatch(use_max_completion_tokens: bool) -> Tuple[APIResponse, UsageTuple | None]:
1588
+ """构造并发起一次 chat.completions 请求。
1589
+
1590
+ Args:
1591
+ use_max_completion_tokens: 为 True 时改用 max_completion_tokens 承载
1592
+ 最大输出 token 数,并省略原生 max_tokens(适配 gpt-5 系列等模型)。
1593
+ """
1594
+ # 每次用副本,避免重试两次之间相互污染 extra_body
1595
+ extra_body = dict(request_overrides.extra_body)
1596
+ if use_max_completion_tokens:
1597
+ # 清除经由 extra_params.body 嵌套传入、未被 reserved_body_keys 过滤的
1598
+ # 遗留 max_tokens,否则重试仍会同时发送 max_tokens 导致再次 400。
1599
+ extra_body.pop("max_tokens", None)
1600
+ # 自动改用 max_completion_tokens:用户未显式给值时用任务级 max_tokens 填充
1601
+ if "max_completion_tokens" not in extra_body and request.max_tokens is not None:
1602
+ extra_body["max_completion_tokens"] = request.max_tokens
1603
+ max_tokens_argument = omit
1604
+ else:
1605
+ max_tokens_argument = (
1606
+ omit
1607
+ if "max_tokens" in extra_body or "max_completion_tokens" in extra_body
1608
+ else _coerce_openai_argument(request.max_tokens)
1609
+ )
1610
+ effective_max_tokens = extra_body.get(
1611
+ "max_completion_tokens",
1612
+ extra_body.get("max_tokens", None if max_tokens_argument is omit else max_tokens_argument),
1613
+ )
1614
+ snapshot_provider_request["request_kwargs"] = {
1615
+ "extra_body": extra_body or None,
1616
+ "extra_headers": request_overrides.extra_headers or None,
1617
+ "extra_query": request_overrides.extra_query or None,
1618
+ "max_tokens": _snapshot_openai_argument(max_tokens_argument),
1619
+ "messages": messages_payload,
1620
+ "model": model_info.model_identifier,
1621
+ "response_format": _snapshot_openai_argument(openai_response_format),
1622
+ "stream": bool(model_info.force_stream_mode),
1623
+ "temperature": _snapshot_openai_argument(temperature_argument),
1624
+ "tools": tools_payload,
1625
+ }
1626
+
1627
+ if model_info.force_stream_mode:
1628
+ active_stream_handler = (
1629
+ stream_response_handler
1630
+ if request.stream_response_handler is not None
1631
+ else self._build_default_stream_response_handler(request.copy_with(max_tokens=effective_max_tokens))
1632
+ )
1633
+ stream_task: asyncio.Task[AsyncStream[ChatCompletionChunk]] = asyncio.create_task(
1634
+ self.client.chat.completions.create(
1635
+ model=model_info.model_identifier,
1636
+ messages=messages_payload,
1637
+ tools=tools_payload or omit,
1638
+ temperature=temperature_argument,
1639
+ max_tokens=max_tokens_argument,
1640
+ stream=True,
1641
+ response_format=openai_response_format,
1642
+ extra_headers=self._build_request_headers(request_overrides.extra_headers),
1643
+ extra_query=request_overrides.extra_query or None,
1644
+ extra_body=extra_body or None,
1645
+ )
1646
+ )
1647
+ raw_response = cast(
1648
+ AsyncStream[ChatCompletionChunk],
1649
+ await await_task_with_interrupt(stream_task, request.interrupt_flag),
1650
+ )
1651
+ try:
1652
+ return await active_stream_handler(raw_response, request.interrupt_flag)
1653
+ finally:
1654
+ # 包括取消路径,及时关闭连接,不保留已失效的响应流。
1655
+ await raw_response.close()
1656
+
1657
+ active_response_parser = (
1658
+ response_parser
1659
+ if request.async_response_parser is not None
1660
+ else self._build_default_response_parser(request.copy_with(max_tokens=effective_max_tokens))
1661
+ )
1662
+ completion_task: asyncio.Task[ChatCompletion] = asyncio.create_task(
1663
+ self.client.chat.completions.create(
1664
+ model=model_info.model_identifier,
1665
+ messages=messages_payload,
1666
+ tools=tools_payload or omit,
1667
+ temperature=temperature_argument,
1668
+ max_tokens=max_tokens_argument,
1669
+ stream=False,
1670
+ response_format=openai_response_format,
1671
+ extra_headers=self._build_request_headers(request_overrides.extra_headers),
1672
+ extra_query=request_overrides.extra_query or None,
1673
+ extra_body=extra_body or None,
1674
+ )
1675
+ )
1676
+ raw_response = cast(
1677
+ ChatCompletion,
1678
+ await await_task_with_interrupt(completion_task, request.interrupt_flag),
1679
+ )
1680
+ return active_response_parser(raw_response)
1681
+
1682
+ # 已知仅支持 max_completion_tokens 的模型直接走对应分支;否则先按常规发送,
1683
+ # 命中「max_tokens 不被支持」的 400 错误时自动改用 max_completion_tokens 重试并记忆。
1684
+ cache_key = (self.api_provider.base_url or "", model_info.model_identifier)
1685
+ use_mct = cache_key in _MODELS_REQUIRING_MAX_COMPLETION_TOKENS
1686
+ # 仅当本次确实发送了原生 max_tokens(显式参数或 extra_body 嵌套传入)时,才把
1687
+ # 「max_tokens 不被支持」的 400 归因于该模型;否则可能把本就没发 max_tokens 的
1688
+ # 请求误记为「必须使用 max_completion_tokens」。
1689
+ sent_legacy_max_tokens = not use_mct and (
1690
+ "max_tokens" in request_overrides.extra_body
1691
+ or (request.max_tokens is not None and "max_completion_tokens" not in request_overrides.extra_body)
1692
+ )
1693
+ try:
1694
+ response_result = await _dispatch(use_mct)
1695
+ except APIStatusError as exc:
1696
+ if sent_legacy_max_tokens and _is_max_tokens_unsupported_error(exc):
1697
+ _MODELS_REQUIRING_MAX_COMPLETION_TOKENS.add(cache_key)
1698
+ logger.info(
1699
+ f"模型 '{model_info.name}' 不支持 max_tokens,自动改用 max_completion_tokens 重试并记忆该模型。"
1700
+ )
1701
+ response_result = await _dispatch(True)
1702
+ else:
1703
+ raise
1704
+ response, usage_record = response_result
1705
+ request_kwargs = snapshot_provider_request["request_kwargs"]
1706
+ response.wire_protocol = "chat_completions"
1707
+ response.request_wire_payload = {
1708
+ "extra_body": request_kwargs.get("extra_body"),
1709
+ "messages": request_kwargs.get("messages"),
1710
+ "response_format": request_kwargs.get("response_format"),
1711
+ "tools": request_kwargs.get("tools"),
1712
+ }
1713
+ return response, usage_record
1714
+ except (EmptyResponseException, RespParseException) as exc:
1715
+ snapshot_path = await asyncio.to_thread(
1716
+ save_failed_request_snapshot,
1717
+ api_provider=self.api_provider,
1718
+ client_type="openai",
1719
+ error=exc,
1720
+ internal_request=serialize_response_request_snapshot(request),
1721
+ model_info=model_info,
1722
+ operation="chat.completions.create",
1723
+ provider_request=snapshot_provider_request,
1724
+ trace_context=request.trace_context,
1725
+ )
1726
+ attach_request_snapshot(exc, snapshot_path)
1727
+ raise
1728
+ except APIConnectionError as exc:
1729
+ snapshot_path = await asyncio.to_thread(
1730
+ save_failed_request_snapshot,
1731
+ api_provider=self.api_provider,
1732
+ client_type="openai",
1733
+ error=exc,
1734
+ internal_request=serialize_response_request_snapshot(request),
1735
+ model_info=model_info,
1736
+ operation="chat.completions.create",
1737
+ provider_request=snapshot_provider_request,
1738
+ trace_context=request.trace_context,
1739
+ )
1740
+ wrapped_error = NetworkConnectionError(str(exc))
1741
+ attach_request_snapshot(wrapped_error, snapshot_path)
1742
+ raise wrapped_error from exc
1743
+ except APIStatusError as exc:
1744
+ snapshot_path = await asyncio.to_thread(
1745
+ save_failed_request_snapshot,
1746
+ api_provider=self.api_provider,
1747
+ client_type="openai",
1748
+ error=exc,
1749
+ internal_request=serialize_response_request_snapshot(request),
1750
+ model_info=model_info,
1751
+ operation="chat.completions.create",
1752
+ provider_request=snapshot_provider_request,
1753
+ trace_context=request.trace_context,
1754
+ )
1755
+ wrapped_error = RespNotOkException(exc.status_code, _build_api_status_message(exc))
1756
+ attach_request_snapshot(wrapped_error, snapshot_path)
1757
+ raise wrapped_error from exc
1758
+ except ReqAbortException:
1759
+ raise
1760
+ except Exception as exc:
1761
+ if has_request_snapshot(exc):
1762
+ raise
1763
+ snapshot_path = await asyncio.to_thread(
1764
+ save_failed_request_snapshot,
1765
+ api_provider=self.api_provider,
1766
+ client_type="openai",
1767
+ error=exc,
1768
+ internal_request=serialize_response_request_snapshot(request),
1769
+ model_info=model_info,
1770
+ operation="chat.completions.create",
1771
+ provider_request=snapshot_provider_request,
1772
+ trace_context=request.trace_context,
1773
+ )
1774
+ attach_request_snapshot(exc, snapshot_path)
1775
+ raise
1776
+
1777
+ async def get_image_embedding(self, request: ImageEmbeddingRequest) -> APIResponse:
1778
+ """调用图片嵌入协议。
1779
+
1780
+ 显式配置 `image_embedding_input` / `image_embedding_body` 时走 OpenAI 兼容模板协议;
1781
+ 否则官方百炼/豆包地址切换原生协议,硅基流动地址自动使用其兼容图片输入模板。
1782
+ """
1783
+
1784
+ extra_params = dict(request.extra_params)
1785
+ if "image_embedding_input" in extra_params or "image_embedding_body" in extra_params:
1786
+ return await self._get_openai_compatible_image_embedding(request, extra_params)
1787
+ native_protocol = resolve_native_image_embedding_protocol(self.api_provider.base_url)
1788
+ if native_protocol is not None:
1789
+ return await self._get_native_image_embedding(request, native_protocol)
1790
+ compatible_input = resolve_compatible_image_embedding_input(self.api_provider.base_url)
1791
+ if compatible_input is not None:
1792
+ extra_params["image_embedding_input"] = compatible_input
1793
+ response = await self._get_openai_compatible_image_embedding(request, extra_params)
1794
+ response.request_protocol_hash = build_compatible_image_embedding_fingerprint(
1795
+ compatible_input, request.extra_params
1796
+ )
1797
+ return response
1798
+ raise ValueError("图片嵌入必须配置 Provider 对应的 image_embedding_input 或 image_embedding_body")
1799
+
1800
+ async def _get_openai_compatible_image_embedding(
1801
+ self,
1802
+ request: ImageEmbeddingRequest,
1803
+ extra_params: Dict[str, Any],
1804
+ ) -> APIResponse:
1805
+ """调用 OpenAI 兼容的图片嵌入扩展协议(模板由 extra_params 提供)。"""
1806
+
1807
+ model_info = request.model_info
1808
+ validate_image_embedding_transport(self.api_provider.base_url)
1809
+ input_template = extra_params.pop("image_embedding_input", None)
1810
+ body_template = extra_params.pop("image_embedding_body", None)
1811
+ encoded = base64.b64encode(request.image_bytes).decode("ascii")
1812
+ replacements = {
1813
+ "base64": encoded,
1814
+ "data_uri": f"data:{request.mime_type};base64,{encoded}",
1815
+ "mime_type": request.mime_type,
1816
+ }
1817
+ image_input = _render_image_embedding_template(input_template, replacements)
1818
+ if body_template is not None:
1819
+ if not isinstance(body_template, dict):
1820
+ raise ValueError("image_embedding_body 必须是对象")
1821
+ rendered_body = _render_image_embedding_template(body_template, replacements)
1822
+ if "input" in rendered_body:
1823
+ image_input = rendered_body.pop("input")
1824
+ extra_params.update(rendered_body)
1825
+ if image_input is None:
1826
+ raise ValueError("图片嵌入请求模板缺少 input")
1827
+ request_overrides = split_openai_request_overrides(extra_params)
1828
+ provider_request = {
1829
+ "base_url": self.api_provider.base_url,
1830
+ "endpoint": "/embeddings",
1831
+ "method": "POST",
1832
+ "operation": "embeddings.create.image",
1833
+ "request_kwargs": {
1834
+ "extra_body": request_overrides.extra_body or None,
1835
+ "extra_headers": request_overrides.extra_headers or None,
1836
+ "extra_query": request_overrides.extra_query or None,
1837
+ "image": {
1838
+ "byte_size": len(request.image_bytes),
1839
+ "mime_type": request.mime_type,
1840
+ },
1841
+ "model": model_info.model_identifier,
1842
+ },
1843
+ }
1844
+ try:
1845
+ raw_response = await self.client.embeddings.create(
1846
+ model=model_info.model_identifier,
1847
+ input=cast(Any, image_input),
1848
+ extra_headers=self._build_request_headers(request_overrides.extra_headers),
1849
+ extra_query=request_overrides.extra_query or None,
1850
+ extra_body=request_overrides.extra_body or None,
1851
+ )
1852
+ except APIConnectionError as exc:
1853
+ wrapped_error: Exception = NetworkConnectionError(str(exc))
1854
+ except APIStatusError as exc:
1855
+ wrapped_error = RespNotOkException(exc.status_code, _build_api_status_message(exc))
1856
+ except Exception as exc:
1857
+ wrapped_error = exc
1858
+ else:
1859
+ if not raw_response.data:
1860
+ wrapped_error = RespParseException(raw_response, "图片嵌入响应缺少 embeddings 数据")
1861
+ else:
1862
+ response = APIResponse(embedding=raw_response.data[0].embedding, raw_data=raw_response)
1863
+ usage_record = _extract_usage_record(getattr(raw_response, "usage", None))
1864
+ if usage_record is not None:
1865
+ response.usage = self._build_usage_record(model_info, usage_record)
1866
+ return response
1867
+
1868
+ snapshot_path = await asyncio.to_thread(
1869
+ save_failed_request_snapshot,
1870
+ api_provider=self.api_provider,
1871
+ client_type="openai",
1872
+ error=wrapped_error,
1873
+ internal_request=serialize_image_embedding_request_snapshot(request),
1874
+ model_info=model_info,
1875
+ operation="embeddings.create.image",
1876
+ provider_request=provider_request,
1877
+ trace_context=request.trace_context,
1878
+ )
1879
+ attach_request_snapshot(wrapped_error, snapshot_path)
1880
+ raise wrapped_error
1881
+
1882
+ async def _get_native_image_embedding(self, request: ImageEmbeddingRequest, protocol: str) -> APIResponse:
1883
+ """调用百炼/豆包官方原生多模态图片嵌入协议。
1884
+
1885
+ 复用同一个 AsyncOpenAI 客户端向原生端点的完整 URL 发送请求:
1886
+ 绝对地址由 SDK 原样使用,鉴权头、默认 headers/query、超时与连接池均不变,
1887
+ 共享 `base_url` 不被修改,聊天与文本嵌入请求不受影响。
1888
+ """
1889
+
1890
+ model_info = request.model_info
1891
+ validate_image_embedding_transport(self.api_provider.base_url)
1892
+ native_request = build_native_image_embedding_request(
1893
+ protocol,
1894
+ base_url=self.api_provider.base_url,
1895
+ model_identifier=model_info.model_identifier,
1896
+ image_bytes=request.image_bytes,
1897
+ mime_type=request.mime_type,
1898
+ extra_params=request.extra_params,
1899
+ )
1900
+ provider_request = {
1901
+ "base_url": self.api_provider.base_url,
1902
+ "endpoint": urlparse(native_request.endpoint_url).path,
1903
+ "method": "POST",
1904
+ "operation": f"{protocol}_image_embedding",
1905
+ "request_kwargs": {
1906
+ "extra_headers": native_request.extra_headers or None,
1907
+ "extra_query": native_request.extra_query or None,
1908
+ "image": {
1909
+ "byte_size": len(request.image_bytes),
1910
+ "mime_type": request.mime_type,
1911
+ },
1912
+ "model": model_info.model_identifier,
1913
+ # 写入快照前用占位符替换 Data URI,避免原图 Base64 落盘
1914
+ "payload": build_native_image_embedding_diagnostic_payload(native_request.payload),
1915
+ },
1916
+ }
1917
+ options: Dict[str, Any] = {}
1918
+ request_headers = self._build_request_headers(native_request.extra_headers)
1919
+ if request_headers:
1920
+ options["headers"] = request_headers
1921
+ if native_request.extra_query:
1922
+ options["params"] = native_request.extra_query
1923
+ try:
1924
+ raw_response = await self.client.post(
1925
+ native_request.endpoint_url,
1926
+ cast_to=object,
1927
+ body=native_request.payload,
1928
+ options=options,
1929
+ )
1930
+ except APIConnectionError as exc:
1931
+ wrapped_error: Exception = NetworkConnectionError(str(exc))
1932
+ except APIStatusError as exc:
1933
+ wrapped_error = RespNotOkException(exc.status_code, _build_api_status_message(exc))
1934
+ else:
1935
+ try:
1936
+ parsed = parse_native_image_embedding_response(protocol, raw_response)
1937
+ except Exception as exc:
1938
+ wrapped_error = RespParseException(raw_response, f"图片嵌入响应解析失败: {exc}")
1939
+ else:
1940
+ response = APIResponse(
1941
+ embedding=parsed.embedding,
1942
+ raw_data=raw_response,
1943
+ request_protocol_hash=build_native_image_embedding_fingerprint(
1944
+ native_request,
1945
+ model_info.model_identifier,
1946
+ ),
1947
+ )
1948
+ if parsed.usage is not None:
1949
+ response.usage = self._build_usage_record(model_info, parsed.usage)
1950
+ return response
1951
+
1952
+ snapshot_path = await asyncio.to_thread(
1953
+ save_failed_request_snapshot,
1954
+ api_provider=self.api_provider,
1955
+ client_type="openai",
1956
+ error=wrapped_error,
1957
+ internal_request=serialize_image_embedding_request_snapshot(request),
1958
+ model_info=model_info,
1959
+ operation=provider_request["operation"],
1960
+ provider_request=provider_request,
1961
+ trace_context=request.trace_context,
1962
+ )
1963
+ attach_request_snapshot(wrapped_error, snapshot_path)
1964
+ raise wrapped_error
1965
+
1966
+ async def _execute_embedding_request(
1967
+ self,
1968
+ request: EmbeddingRequest,
1969
+ ) -> Tuple[APIResponse, UsageTuple | None]:
1970
+ """执行 OpenAI 兼容的文本嵌入请求。
1971
+
1972
+ Args:
1973
+ request: 统一嵌入请求对象。
1974
+
1975
+ Returns:
1976
+ Tuple[APIResponse, UsageTuple | None]: 统一响应对象与可选使用量信息。
1977
+ """
1978
+ model_info = request.model_info
1979
+ embedding_input = request.embedding_input
1980
+ extra_params = request.extra_params
1981
+ snapshot_provider_request = {
1982
+ "base_url": self.api_provider.base_url,
1983
+ "endpoint": "/embeddings",
1984
+ "method": "POST",
1985
+ "operation": "embeddings.create",
1986
+ "organization": self.api_provider.organization,
1987
+ "project": self.api_provider.project,
1988
+ "request_kwargs": {},
1989
+ }
1990
+
1991
+ try:
1992
+ request_overrides = split_openai_request_overrides(extra_params)
1993
+ snapshot_provider_request["request_kwargs"] = {
1994
+ "extra_body": request_overrides.extra_body or None,
1995
+ "extra_headers": request_overrides.extra_headers or None,
1996
+ "extra_query": request_overrides.extra_query or None,
1997
+ "input": embedding_input,
1998
+ "model": model_info.model_identifier,
1999
+ }
2000
+ raw_response = await self.client.embeddings.create(
2001
+ model=model_info.model_identifier,
2002
+ input=embedding_input,
2003
+ extra_headers=self._build_request_headers(request_overrides.extra_headers),
2004
+ extra_query=request_overrides.extra_query or None,
2005
+ extra_body=request_overrides.extra_body or None,
2006
+ )
2007
+ except APIConnectionError as exc:
2008
+ snapshot_path = await asyncio.to_thread(
2009
+ save_failed_request_snapshot,
2010
+ api_provider=self.api_provider,
2011
+ client_type="openai",
2012
+ error=exc,
2013
+ internal_request=serialize_embedding_request_snapshot(request),
2014
+ model_info=model_info,
2015
+ operation="embeddings.create",
2016
+ provider_request=snapshot_provider_request,
2017
+ trace_context=request.trace_context,
2018
+ )
2019
+ wrapped_error = NetworkConnectionError(str(exc))
2020
+ attach_request_snapshot(wrapped_error, snapshot_path)
2021
+ raise wrapped_error from exc
2022
+ except APIStatusError as exc:
2023
+ snapshot_path = await asyncio.to_thread(
2024
+ save_failed_request_snapshot,
2025
+ api_provider=self.api_provider,
2026
+ client_type="openai",
2027
+ error=exc,
2028
+ internal_request=serialize_embedding_request_snapshot(request),
2029
+ model_info=model_info,
2030
+ operation="embeddings.create",
2031
+ provider_request=snapshot_provider_request,
2032
+ trace_context=request.trace_context,
2033
+ )
2034
+ wrapped_error = RespNotOkException(exc.status_code, _build_api_status_message(exc))
2035
+ attach_request_snapshot(wrapped_error, snapshot_path)
2036
+ raise wrapped_error from exc
2037
+ except Exception as exc:
2038
+ if has_request_snapshot(exc):
2039
+ raise
2040
+ snapshot_path = await asyncio.to_thread(
2041
+ save_failed_request_snapshot,
2042
+ api_provider=self.api_provider,
2043
+ client_type="openai",
2044
+ error=exc,
2045
+ internal_request=serialize_embedding_request_snapshot(request),
2046
+ model_info=model_info,
2047
+ operation="embeddings.create",
2048
+ provider_request=snapshot_provider_request,
2049
+ trace_context=request.trace_context,
2050
+ )
2051
+ attach_request_snapshot(exc, snapshot_path)
2052
+ raise
2053
+
2054
+ response = APIResponse()
2055
+ if not raw_response.data:
2056
+ exc = RespParseException(raw_response, "嵌入响应解析失败,缺少 embeddings 数据。")
2057
+ snapshot_path = await asyncio.to_thread(
2058
+ save_failed_request_snapshot,
2059
+ api_provider=self.api_provider,
2060
+ client_type="openai",
2061
+ error=exc,
2062
+ internal_request=serialize_embedding_request_snapshot(request),
2063
+ model_info=model_info,
2064
+ operation="embeddings.create",
2065
+ provider_request=snapshot_provider_request,
2066
+ trace_context=request.trace_context,
2067
+ )
2068
+ attach_request_snapshot(exc, snapshot_path)
2069
+ raise exc
2070
+ if raw_response.data:
2071
+ response.embedding = raw_response.data[0].embedding
2072
+ else:
2073
+ raise RespParseException(raw_response, "响应解析失败,缺失嵌入数据。")
2074
+
2075
+ usage_record = _extract_usage_record(getattr(raw_response, "usage", None))
2076
+ return response, usage_record
2077
+
2078
+ async def _execute_audio_transcription_request(
2079
+ self,
2080
+ request: AudioTranscriptionRequest,
2081
+ ) -> Tuple[APIResponse, UsageTuple | None]:
2082
+ """执行 OpenAI 兼容的音频转录请求。
2083
+
2084
+ Args:
2085
+ request: 统一音频转录请求对象。
2086
+
2087
+ Returns:
2088
+ Tuple[APIResponse, UsageTuple | None]: 统一响应对象与可选使用量信息。
2089
+ """
2090
+ model_info = request.model_info
2091
+ audio_base64 = request.audio_base64
2092
+ extra_params = request.extra_params
2093
+ snapshot_provider_request = {
2094
+ "base_url": self.api_provider.base_url,
2095
+ "endpoint": "/audio/transcriptions",
2096
+ "method": "POST",
2097
+ "operation": "audio.transcriptions.create",
2098
+ "organization": self.api_provider.organization,
2099
+ "project": self.api_provider.project,
2100
+ "request_kwargs": {},
2101
+ }
2102
+
2103
+ try:
2104
+ request_overrides = split_openai_request_overrides(extra_params)
2105
+ audio_file: FileTypes = ("audio.wav", io.BytesIO(base64.b64decode(audio_base64)))
2106
+ snapshot_provider_request["request_kwargs"] = {
2107
+ "audio_base64": audio_base64,
2108
+ "extra_body": request_overrides.extra_body or None,
2109
+ "extra_headers": request_overrides.extra_headers or None,
2110
+ "extra_query": request_overrides.extra_query or None,
2111
+ "file_name": "audio.wav",
2112
+ "model": model_info.model_identifier,
2113
+ }
2114
+ raw_response = await self.client.audio.transcriptions.create(
2115
+ model=model_info.model_identifier,
2116
+ file=audio_file,
2117
+ extra_headers=self._build_request_headers(request_overrides.extra_headers),
2118
+ extra_query=request_overrides.extra_query or None,
2119
+ extra_body=request_overrides.extra_body or None,
2120
+ )
2121
+ except APIConnectionError as exc:
2122
+ snapshot_path = await asyncio.to_thread(
2123
+ save_failed_request_snapshot,
2124
+ api_provider=self.api_provider,
2125
+ client_type="openai",
2126
+ error=exc,
2127
+ internal_request=serialize_audio_request_snapshot(request),
2128
+ model_info=model_info,
2129
+ operation="audio.transcriptions.create",
2130
+ provider_request=snapshot_provider_request,
2131
+ trace_context=request.trace_context,
2132
+ )
2133
+ wrapped_error = NetworkConnectionError(str(exc))
2134
+ attach_request_snapshot(wrapped_error, snapshot_path)
2135
+ raise wrapped_error from exc
2136
+ except APIStatusError as exc:
2137
+ snapshot_path = await asyncio.to_thread(
2138
+ save_failed_request_snapshot,
2139
+ api_provider=self.api_provider,
2140
+ client_type="openai",
2141
+ error=exc,
2142
+ internal_request=serialize_audio_request_snapshot(request),
2143
+ model_info=model_info,
2144
+ operation="audio.transcriptions.create",
2145
+ provider_request=snapshot_provider_request,
2146
+ trace_context=request.trace_context,
2147
+ )
2148
+ wrapped_error = RespNotOkException(exc.status_code, _build_api_status_message(exc))
2149
+ attach_request_snapshot(wrapped_error, snapshot_path)
2150
+ raise wrapped_error from exc
2151
+ except Exception as exc:
2152
+ if has_request_snapshot(exc):
2153
+ raise
2154
+ snapshot_path = await asyncio.to_thread(
2155
+ save_failed_request_snapshot,
2156
+ api_provider=self.api_provider,
2157
+ client_type="openai",
2158
+ error=exc,
2159
+ internal_request=serialize_audio_request_snapshot(request),
2160
+ model_info=model_info,
2161
+ operation="audio.transcriptions.create",
2162
+ provider_request=snapshot_provider_request,
2163
+ trace_context=request.trace_context,
2164
+ )
2165
+ attach_request_snapshot(exc, snapshot_path)
2166
+ raise
2167
+
2168
+ transcription_text = raw_response if isinstance(raw_response, str) else getattr(raw_response, "text", None)
2169
+ if not isinstance(transcription_text, str):
2170
+ exc = RespParseException(raw_response, "音频转写响应解析失败,缺少文本内容。")
2171
+ snapshot_path = await asyncio.to_thread(
2172
+ save_failed_request_snapshot,
2173
+ api_provider=self.api_provider,
2174
+ client_type="openai",
2175
+ error=exc,
2176
+ internal_request=serialize_audio_request_snapshot(request),
2177
+ model_info=model_info,
2178
+ operation="audio.transcriptions.create",
2179
+ provider_request=snapshot_provider_request,
2180
+ trace_context=request.trace_context,
2181
+ )
2182
+ attach_request_snapshot(exc, snapshot_path)
2183
+ raise exc
2184
+ if isinstance(transcription_text, str):
2185
+ return _build_portable_api_response(
2186
+ content=transcription_text,
2187
+ reasoning_content=None,
2188
+ tool_calls=None,
2189
+ logical_turn_id=uuid4().hex,
2190
+ raw_data=raw_response,
2191
+ ), None
2192
+ raise RespParseException(raw_response, "响应解析失败,缺失转录文本。")
2193
+
2194
+ def get_support_image_formats(self) -> List[str]:
2195
+ """获取支持的图片格式列表。
2196
+
2197
+ Returns:
2198
+ List[str]: 当前客户端支持的图片格式列表。
2199
+ """
2200
+ return ["jpg", "jpeg", "png", "webp", "gif"]