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.
- maibot-1.3.8.dist-info/METADATA +245 -0
- maibot-1.3.8.dist-info/RECORD +716 -0
- maibot-1.3.8.dist-info/WHEEL +5 -0
- maibot-1.3.8.dist-info/entry_points.txt +2 -0
- maibot-1.3.8.dist-info/licenses/LICENSE +674 -0
- maibot-1.3.8.dist-info/top_level.txt +1 -0
- src/A_memorix/LICENSE +661 -0
- src/A_memorix/LICENSE-MAIBOT-GPL.md +22 -0
- src/A_memorix/__init__.py +5 -0
- src/A_memorix/config_schema.json +1640 -0
- src/A_memorix/core/__init__.py +83 -0
- src/A_memorix/core/embedding/__init__.py +17 -0
- src/A_memorix/core/embedding/api_adapter.py +650 -0
- src/A_memorix/core/embedding/manager.py +506 -0
- src/A_memorix/core/embedding/presets.py +68 -0
- src/A_memorix/core/image/__init__.py +6 -0
- src/A_memorix/core/image/asset_store.py +141 -0
- src/A_memorix/core/image/component_paths.py +36 -0
- src/A_memorix/core/image/embedder.py +129 -0
- src/A_memorix/core/image/fingerprints.py +36 -0
- src/A_memorix/core/image/runtime.py +900 -0
- src/A_memorix/core/retrieval/__init__.py +66 -0
- src/A_memorix/core/retrieval/dual_path.py +3272 -0
- src/A_memorix/core/retrieval/graph_relation_recall.py +285 -0
- src/A_memorix/core/retrieval/pagerank.py +471 -0
- src/A_memorix/core/retrieval/posterior_graph.py +787 -0
- src/A_memorix/core/retrieval/score_calibration.py +180 -0
- src/A_memorix/core/retrieval/sparse_bm25.py +717 -0
- src/A_memorix/core/retrieval/threshold.py +404 -0
- src/A_memorix/core/runtime/__init__.py +26 -0
- src/A_memorix/core/runtime/admin_contracts.py +293 -0
- src/A_memorix/core/runtime/kernel_compat.py +269 -0
- src/A_memorix/core/runtime/lifecycle_orchestrator.py +368 -0
- src/A_memorix/core/runtime/models.py +27 -0
- src/A_memorix/core/runtime/runtime_facade.py +141 -0
- src/A_memorix/core/runtime/runtime_writer_lock.py +105 -0
- src/A_memorix/core/runtime/sdk_memory_kernel.py +1907 -0
- src/A_memorix/core/runtime/search_runtime_initializer.py +290 -0
- src/A_memorix/core/runtime/services/__init__.py +63 -0
- src/A_memorix/core/runtime/services/background_task_service.py +477 -0
- src/A_memorix/core/runtime/services/base.py +28 -0
- src/A_memorix/core/runtime/services/bundle_admin_service.py +2200 -0
- src/A_memorix/core/runtime/services/chat_filter_service.py +88 -0
- src/A_memorix/core/runtime/services/correction_admin_service.py +1274 -0
- src/A_memorix/core/runtime/services/delete_admin_service.py +2018 -0
- src/A_memorix/core/runtime/services/dual_vector_migration_service.py +109 -0
- src/A_memorix/core/runtime/services/dual_vector_state_service.py +503 -0
- src/A_memorix/core/runtime/services/embedding_state_service.py +515 -0
- src/A_memorix/core/runtime/services/episode_admin_service.py +104 -0
- src/A_memorix/core/runtime/services/fact_admin_service.py +199 -0
- src/A_memorix/core/runtime/services/feedback_correction_service.py +1523 -0
- src/A_memorix/core/runtime/services/graph_admin_service.py +1521 -0
- src/A_memorix/core/runtime/services/image_service.py +158 -0
- src/A_memorix/core/runtime/services/import_tuning_admin_service.py +158 -0
- src/A_memorix/core/runtime/services/ingest_service.py +787 -0
- src/A_memorix/core/runtime/services/memory_maintenance_service.py +404 -0
- src/A_memorix/core/runtime/services/memory_search_service.py +373 -0
- src/A_memorix/core/runtime/services/profile_admin_service.py +646 -0
- src/A_memorix/core/runtime/services/request_dedup_service.py +34 -0
- src/A_memorix/core/runtime/services/runtime_config_service.py +94 -0
- src/A_memorix/core/runtime/services/runtime_dependency_service.py +105 -0
- src/A_memorix/core/runtime/services/runtime_lifecycle_service.py +409 -0
- src/A_memorix/core/runtime/services/search_hit_processing_service.py +891 -0
- src/A_memorix/core/runtime/services/source_admin_service.py +52 -0
- src/A_memorix/core/runtime/services/stats_service.py +35 -0
- src/A_memorix/core/runtime/services/summary_service.py +50 -0
- src/A_memorix/core/runtime/services/v5_admin_service.py +326 -0
- src/A_memorix/core/runtime/services/vector_delete_service.py +30 -0
- src/A_memorix/core/runtime/services/vector_rebuild_checkpoint.py +65 -0
- src/A_memorix/core/runtime/services/vector_recovery_service.py +460 -0
- src/A_memorix/core/runtime/services/vector_runtime_service.py +1417 -0
- src/A_memorix/core/runtime/services/vector_space_service.py +251 -0
- src/A_memorix/core/storage/__init__.py +62 -0
- src/A_memorix/core/storage/format_migration.py +676 -0
- src/A_memorix/core/storage/graph_store.py +1733 -0
- src/A_memorix/core/storage/knowledge_types.py +183 -0
- src/A_memorix/core/storage/metadata_cleanup.py +913 -0
- src/A_memorix/core/storage/metadata_episode.py +1659 -0
- src/A_memorix/core/storage/metadata_fact.py +1410 -0
- src/A_memorix/core/storage/metadata_feedback.py +745 -0
- src/A_memorix/core/storage/metadata_fts.py +937 -0
- src/A_memorix/core/storage/metadata_image.py +1076 -0
- src/A_memorix/core/storage/metadata_profile.py +782 -0
- src/A_memorix/core/storage/metadata_schema.py +2503 -0
- src/A_memorix/core/storage/metadata_store.py +4565 -0
- src/A_memorix/core/storage/sqlite_connection.py +176 -0
- src/A_memorix/core/storage/tokenizer_runtime.py +9 -0
- src/A_memorix/core/storage/transaction.py +42 -0
- src/A_memorix/core/storage/type_detection.py +137 -0
- src/A_memorix/core/storage/value_coercion.py +10 -0
- src/A_memorix/core/storage/vector_store.py +2851 -0
- src/A_memorix/core/strategies/__init__.py +0 -0
- src/A_memorix/core/strategies/base.py +92 -0
- src/A_memorix/core/strategies/chat_log.py +80 -0
- src/A_memorix/core/strategies/factual.py +104 -0
- src/A_memorix/core/strategies/narrative.py +148 -0
- src/A_memorix/core/strategies/quote.py +47 -0
- src/A_memorix/core/utils/__init__.py +33 -0
- src/A_memorix/core/utils/aggregate_query_service.py +359 -0
- src/A_memorix/core/utils/episode_retrieval_service.py +222 -0
- src/A_memorix/core/utils/episode_segmentation_service.py +322 -0
- src/A_memorix/core/utils/episode_service.py +646 -0
- src/A_memorix/core/utils/feedback_policy.py +149 -0
- src/A_memorix/core/utils/hash.py +128 -0
- src/A_memorix/core/utils/import_payloads.py +222 -0
- src/A_memorix/core/utils/io.py +85 -0
- src/A_memorix/core/utils/matcher.py +175 -0
- src/A_memorix/core/utils/memory_lifecycle_policy.py +336 -0
- src/A_memorix/core/utils/metadata.py +11 -0
- src/A_memorix/core/utils/model_routing.py +239 -0
- src/A_memorix/core/utils/monitor.py +188 -0
- src/A_memorix/core/utils/path_fallback_service.py +182 -0
- src/A_memorix/core/utils/person_profile_service.py +1277 -0
- src/A_memorix/core/utils/plugin_id_policy.py +26 -0
- src/A_memorix/core/utils/profile_evidence.py +25 -0
- src/A_memorix/core/utils/profile_policy.py +45 -0
- src/A_memorix/core/utils/profile_text.py +204 -0
- src/A_memorix/core/utils/quantization.py +343 -0
- src/A_memorix/core/utils/relation_query.py +121 -0
- src/A_memorix/core/utils/relation_write_service.py +504 -0
- src/A_memorix/core/utils/retrieval_tuning_manager.py +2369 -0
- src/A_memorix/core/utils/runtime_payloads.py +132 -0
- src/A_memorix/core/utils/runtime_self_check.py +303 -0
- src/A_memorix/core/utils/search_execution_service.py +445 -0
- src/A_memorix/core/utils/search_postprocess.py +91 -0
- src/A_memorix/core/utils/summary_importer.py +991 -0
- src/A_memorix/core/utils/time_parser.py +156 -0
- src/A_memorix/core/utils/web_import_manager.py +4511 -0
- src/A_memorix/host_service.py +1018 -0
- src/A_memorix/paths.py +58 -0
- src/A_memorix/plugin.py +321 -0
- src/A_memorix/runtime_registry.py +30 -0
- src/A_memorix/scripts/_bootstrap.py +22 -0
- src/A_memorix/scripts/audit_vector_consistency.py +205 -0
- src/A_memorix/scripts/backfill_relation_vectors.py +265 -0
- src/A_memorix/scripts/backfill_temporal_metadata.py +67 -0
- src/A_memorix/scripts/convert_lpmm.py +537 -0
- src/A_memorix/scripts/import_lpmm_json.py +189 -0
- src/A_memorix/scripts/migrate_chat_history.py +101 -0
- src/A_memorix/scripts/migrate_maibot_memory.py +1955 -0
- src/A_memorix/scripts/migrate_person_memory_points.py +113 -0
- src/A_memorix/scripts/process_knowledge.py +831 -0
- src/A_memorix/scripts/rebuild_episodes.py +207 -0
- src/A_memorix/scripts/release_vnext_migrate.py +875 -0
- src/A_memorix/scripts/runtime_self_check.py +144 -0
- src/__init__.py +14 -0
- src/_resources/EULA.md +135 -0
- src/_resources/PRIVACY.md +60 -0
- src/_resources/changelogs/changelog.md +918 -0
- src/_resources/locales/en-US/config.json +39 -0
- src/_resources/locales/en-US/core.json +8 -0
- src/_resources/locales/en-US/prompts.json +8 -0
- src/_resources/locales/en-US/startup.json +98 -0
- src/_resources/locales/ja-JP/config.json +39 -0
- src/_resources/locales/ja-JP/core.json +8 -0
- src/_resources/locales/ja-JP/prompts.json +8 -0
- src/_resources/locales/ja-JP/startup.json +98 -0
- src/_resources/locales/ko/config.json +39 -0
- src/_resources/locales/ko/core.json +8 -0
- src/_resources/locales/ko/prompts.json +8 -0
- src/_resources/locales/ko/startup.json +98 -0
- src/_resources/locales/zh-CN/config.json +39 -0
- src/_resources/locales/zh-CN/core.json +8 -0
- src/_resources/locales/zh-CN/prompts.json +8 -0
- src/_resources/locales/zh-CN/startup.json +98 -0
- src/_resources/prompts/en-US/.meta.toml +164 -0
- src/_resources/prompts/en-US/a_memorix_chat_summary.prompt +45 -0
- src/_resources/prompts/en-US/emoji_content_analysis.prompt +3 -0
- src/_resources/prompts/en-US/emoji_content_filtration.prompt +6 -0
- src/_resources/prompts/en-US/emoji_replace.prompt +12 -0
- src/_resources/prompts/en-US/emoji_selection.prompt +6 -0
- src/_resources/prompts/en-US/expression_evaluation.prompt +14 -0
- src/_resources/prompts/en-US/heuristic_memory_impression.prompt +15 -0
- src/_resources/prompts/en-US/image_description.prompt +1 -0
- src/_resources/prompts/en-US/jargon_compare_inference.prompt +14 -0
- src/_resources/prompts/en-US/jargon_inference_content_only.prompt +11 -0
- src/_resources/prompts/en-US/jargon_inference_with_context.prompt +19 -0
- src/_resources/prompts/en-US/learn_jargon.prompt +31 -0
- src/_resources/prompts/en-US/learn_style.prompt +28 -0
- src/_resources/prompts/en-US/maisaka_chat.prompt +28 -0
- src/_resources/prompts/en-US/maisaka_chat_focus.prompt +39 -0
- src/_resources/prompts/en-US/maisaka_chat_optimized.prompt +19 -0
- src/_resources/prompts/en-US/maisaka_replyer.prompt +10 -0
- src/_resources/prompts/en-US/memory_fuzzy_modify_plan.prompt +45 -0
- src/_resources/prompts/en-US/mid_term_memory_summary.prompt +10 -0
- src/_resources/prompts/en-US/reply_attachments.prompt +3 -0
- src/_resources/prompts/en-US/retro_private_replyer.prompt +13 -0
- src/_resources/prompts/en-US/retro_private_replyer_self.prompt +13 -0
- src/_resources/prompts/en-US/retro_replyer.prompt +17 -0
- src/_resources/prompts/en-US/retro_replyer_light.prompt +17 -0
- src/_resources/prompts/en-US/webui_ai_search.prompt +24 -0
- src/_resources/prompts/en-US/webui_ai_search_correction.prompt +1 -0
- src/_resources/prompts/en-US/webui_ai_search_evidence.prompt +3 -0
- src/_resources/prompts/en-US/webui_ai_search_final.prompt +1 -0
- src/_resources/prompts/ja-JP/.meta.toml +164 -0
- src/_resources/prompts/ja-JP/a_memorix_chat_summary.prompt +45 -0
- src/_resources/prompts/ja-JP/emoji_content_analysis.prompt +3 -0
- src/_resources/prompts/ja-JP/emoji_content_filtration.prompt +6 -0
- src/_resources/prompts/ja-JP/emoji_replace.prompt +12 -0
- src/_resources/prompts/ja-JP/emoji_selection.prompt +6 -0
- src/_resources/prompts/ja-JP/expression_evaluation.prompt +14 -0
- src/_resources/prompts/ja-JP/heuristic_memory_impression.prompt +15 -0
- src/_resources/prompts/ja-JP/image_description.prompt +1 -0
- src/_resources/prompts/ja-JP/jargon_compare_inference.prompt +14 -0
- src/_resources/prompts/ja-JP/jargon_inference_content_only.prompt +11 -0
- src/_resources/prompts/ja-JP/jargon_inference_with_context.prompt +19 -0
- src/_resources/prompts/ja-JP/learn_jargon.prompt +31 -0
- src/_resources/prompts/ja-JP/learn_style.prompt +28 -0
- src/_resources/prompts/ja-JP/maisaka_chat.prompt +28 -0
- src/_resources/prompts/ja-JP/maisaka_chat_focus.prompt +39 -0
- src/_resources/prompts/ja-JP/maisaka_chat_optimized.prompt +19 -0
- src/_resources/prompts/ja-JP/maisaka_replyer.prompt +10 -0
- src/_resources/prompts/ja-JP/memory_fuzzy_modify_plan.prompt +45 -0
- src/_resources/prompts/ja-JP/mid_term_memory_summary.prompt +10 -0
- src/_resources/prompts/ja-JP/reply_attachments.prompt +3 -0
- src/_resources/prompts/ja-JP/retro_private_replyer.prompt +13 -0
- src/_resources/prompts/ja-JP/retro_private_replyer_self.prompt +13 -0
- src/_resources/prompts/ja-JP/retro_replyer.prompt +17 -0
- src/_resources/prompts/ja-JP/retro_replyer_light.prompt +17 -0
- src/_resources/prompts/ja-JP/webui_ai_search.prompt +24 -0
- src/_resources/prompts/ja-JP/webui_ai_search_correction.prompt +1 -0
- src/_resources/prompts/ja-JP/webui_ai_search_evidence.prompt +3 -0
- src/_resources/prompts/ja-JP/webui_ai_search_final.prompt +1 -0
- src/_resources/prompts/ko/.meta.toml +4 -0
- src/_resources/prompts/ko/reply_attachments.prompt +3 -0
- src/_resources/prompts/zh-CN/.meta.toml +159 -0
- src/_resources/prompts/zh-CN/a_memorix_chat_summary.prompt +59 -0
- src/_resources/prompts/zh-CN/emoji_content_analysis.prompt +3 -0
- src/_resources/prompts/zh-CN/emoji_content_filtration.prompt +6 -0
- src/_resources/prompts/zh-CN/emoji_replace.prompt +12 -0
- src/_resources/prompts/zh-CN/emoji_selection.prompt +6 -0
- src/_resources/prompts/zh-CN/expression_evaluation.prompt +14 -0
- src/_resources/prompts/zh-CN/heuristic_memory_impression.prompt +15 -0
- src/_resources/prompts/zh-CN/image_description.prompt +1 -0
- src/_resources/prompts/zh-CN/jargon_compare_inference.prompt +14 -0
- src/_resources/prompts/zh-CN/jargon_inference_content_only.prompt +11 -0
- src/_resources/prompts/zh-CN/jargon_inference_with_context.prompt +19 -0
- src/_resources/prompts/zh-CN/learn_jargon.prompt +31 -0
- src/_resources/prompts/zh-CN/learn_style.prompt +28 -0
- src/_resources/prompts/zh-CN/maisaka_chat.prompt +28 -0
- src/_resources/prompts/zh-CN/maisaka_chat_focus.prompt +39 -0
- src/_resources/prompts/zh-CN/maisaka_chat_optimized.prompt +19 -0
- src/_resources/prompts/zh-CN/maisaka_replyer.prompt +6 -0
- src/_resources/prompts/zh-CN/memory_fuzzy_modify_plan.prompt +45 -0
- src/_resources/prompts/zh-CN/mid_term_memory_summary.prompt +10 -0
- src/_resources/prompts/zh-CN/reply_attachments.prompt +3 -0
- src/_resources/prompts/zh-CN/retro_private_replyer.prompt +13 -0
- src/_resources/prompts/zh-CN/retro_private_replyer_self.prompt +13 -0
- src/_resources/prompts/zh-CN/retro_replyer.prompt +17 -0
- src/_resources/prompts/zh-CN/retro_replyer_light.prompt +17 -0
- src/_resources/prompts/zh-CN/webui_ai_search.prompt +24 -0
- src/_resources/prompts/zh-CN/webui_ai_search_correction.prompt +1 -0
- src/_resources/prompts/zh-CN/webui_ai_search_evidence.prompt +3 -0
- src/_resources/prompts/zh-CN/webui_ai_search_final.prompt +1 -0
- src/_resources/pyproject.toml +156 -0
- src/chat/__init__.py +13 -0
- src/chat/heart_flow/heartFC_utils.py +29 -0
- src/chat/heart_flow/heartflow_manager.py +138 -0
- src/chat/heart_flow/heartflow_message_processor.py +104 -0
- src/chat/image_system/image_cache_cleanup.py +300 -0
- src/chat/image_system/image_manager.py +516 -0
- src/chat/message_receive/__init__.py +5 -0
- src/chat/message_receive/bot.py +893 -0
- src/chat/message_receive/chat_manager.py +540 -0
- src/chat/message_receive/image_receive_compressor.py +135 -0
- src/chat/message_receive/message.py +546 -0
- src/chat/message_receive/uni_message_sender.py +332 -0
- src/chat/replyer/expression_vector_index.py +3176 -0
- src/chat/replyer/maisaka_expression_selector.py +678 -0
- src/chat/replyer/maisaka_generator.py +25 -0
- src/chat/replyer/maisaka_generator_base.py +1457 -0
- src/chat/replyer/replyer_manager.py +62 -0
- src/chat/replyer/retro_prompt.py +512 -0
- src/chat/utils/llm_sentence_splitter.py +151 -0
- src/chat/utils/statistic.py +3170 -0
- src/chat/utils/typo_generator.py +495 -0
- src/chat/utils/utils.py +922 -0
- src/cli/__init__.py +3 -0
- src/cli/bot_console.py +281 -0
- src/cli/console.py +17 -0
- src/cli/input_reader.py +55 -0
- src/cli/maisaka_cli.py +122 -0
- src/cli/maisaka_cli_sender.py +28 -0
- src/cli/tool.py +62 -0
- src/common/__init__.py +1 -0
- src/common/data_models/__init__.py +28 -0
- src/common/data_models/chat_session_data_model.py +73 -0
- src/common/data_models/chat_target_info_data_model.py +73 -0
- src/common/data_models/embedding_service_data_models.py +31 -0
- src/common/data_models/expression_data_model.py +83 -0
- src/common/data_models/image_data_model.py +247 -0
- src/common/data_models/jargon_data_model.py +101 -0
- src/common/data_models/llm_data_model.py +23 -0
- src/common/data_models/llm_service_data_models.py +298 -0
- src/common/data_models/mai_message_data_model.py +226 -0
- src/common/data_models/message_component_data_model.py +560 -0
- src/common/data_models/person_info_data_model.py +177 -0
- src/common/data_models/reply_generation_data_models.py +275 -0
- src/common/data_models/tool_record_data_model.py +51 -0
- src/common/database/__init__.py +0 -0
- src/common/database/database.py +240 -0
- src/common/database/database_model.py +477 -0
- src/common/database/migrations/__init__.py +161 -0
- src/common/database/migrations/behavior_generic_tags.py +532 -0
- src/common/database/migrations/bootstrap.py +171 -0
- src/common/database/migrations/builtin.py +1966 -0
- src/common/database/migrations/exceptions.py +33 -0
- src/common/database/migrations/frozen_v2_schema.py +282 -0
- src/common/database/migrations/legacy_v1_to_v2.py +1300 -0
- src/common/database/migrations/manager.py +209 -0
- src/common/database/migrations/models.py +305 -0
- src/common/database/migrations/planner.py +108 -0
- src/common/database/migrations/progress.py +319 -0
- src/common/database/migrations/registry.py +98 -0
- src/common/database/migrations/resolver.py +135 -0
- src/common/database/migrations/schema.py +98 -0
- src/common/database/migrations/v10_to_v11.py +60 -0
- src/common/database/migrations/v11_to_v12.py +92 -0
- src/common/database/migrations/v12_to_v13.py +42 -0
- src/common/database/migrations/v13_to_v14.py +55 -0
- src/common/database/migrations/v14_to_v15.py +44 -0
- src/common/database/migrations/v15_to_v16.py +39 -0
- src/common/database/migrations/v16_to_v17.py +66 -0
- src/common/database/migrations/v17_to_v18.py +64 -0
- src/common/database/migrations/v18_to_v19.py +355 -0
- src/common/database/migrations/v19_to_v20.py +169 -0
- src/common/database/migrations/v20_to_v21.py +149 -0
- src/common/database/migrations/v21_to_v22.py +343 -0
- src/common/database/migrations/v22_to_v23.py +114 -0
- src/common/database/migrations/v23_to_v24.py +465 -0
- src/common/database/migrations/v24_to_v25.py +96 -0
- src/common/database/migrations/v25_to_v26.py +32 -0
- src/common/database/migrations/v26_to_v27.py +86 -0
- src/common/database/migrations/v27_to_v28.py +185 -0
- src/common/database/migrations/v28_to_v29.py +80 -0
- src/common/database/migrations/v29_to_v30.py +217 -0
- src/common/database/migrations/v2_to_v3.py +269 -0
- src/common/database/migrations/v30_to_v31.py +323 -0
- src/common/database/migrations/v31_to_v32.py +250 -0
- src/common/database/migrations/v32_to_v33.py +84 -0
- src/common/database/migrations/v33_to_v34.py +119 -0
- src/common/database/migrations/v34_to_v35.py +68 -0
- src/common/database/migrations/v35_to_v36.py +35 -0
- src/common/database/migrations/v36_to_v37.py +54 -0
- src/common/database/migrations/v37_to_v38.py +95 -0
- src/common/database/migrations/v38_to_v39.py +85 -0
- src/common/database/migrations/v39_to_v40.py +53 -0
- src/common/database/migrations/v3_to_v4.py +155 -0
- src/common/database/migrations/v40_to_v41.py +23 -0
- src/common/database/migrations/v41_to_v42.py +44 -0
- src/common/database/migrations/v42_to_v43.py +29 -0
- src/common/database/migrations/v4_to_v5.py +77 -0
- src/common/database/migrations/v5_to_v6.py +51 -0
- src/common/database/migrations/v6_to_v7.py +164 -0
- src/common/database/migrations/v7_to_v8.py +73 -0
- src/common/database/migrations/v8_to_v9.py +313 -0
- src/common/database/migrations/v9_to_v10.py +53 -0
- src/common/database/migrations/version_store.py +57 -0
- src/common/embedding_identity.py +43 -0
- src/common/event_loop_watchdog.py +72 -0
- src/common/i18n/__init__.py +70 -0
- src/common/i18n/exceptions.py +18 -0
- src/common/i18n/formatting.py +29 -0
- src/common/i18n/loaders.py +162 -0
- src/common/i18n/manager.py +201 -0
- src/common/logger.py +1009 -0
- src/common/logger_color_and_mapping.py +430 -0
- src/common/message_repository.py +287 -0
- src/common/message_server/__init__.py +19 -0
- src/common/message_server/api.py +190 -0
- src/common/message_server/server.py +130 -0
- src/common/operation_timing.py +52 -0
- src/common/paths.py +32 -0
- src/common/process_runner.py +142 -0
- src/common/prompt_i18n.py +416 -0
- src/common/remote.py +288 -0
- src/common/reply_effect_fingerprint.py +119 -0
- src/common/reply_effect_record_codec.py +40 -0
- src/common/runtime_loop.py +31 -0
- src/common/self_update.py +580 -0
- src/common/shutdown.py +55 -0
- src/common/update_notice.py +351 -0
- src/common/utils/image_path.py +39 -0
- src/common/utils/math_utils.py +122 -0
- src/common/utils/port_checker.py +97 -0
- src/common/utils/system_utils.py +11 -0
- src/common/utils/utils_action.py +32 -0
- src/common/utils/utils_config.py +613 -0
- src/common/utils/utils_file.py +56 -0
- src/common/utils/utils_image.py +257 -0
- src/common/utils/utils_message.py +871 -0
- src/common/utils/utils_person.py +55 -0
- src/common/utils/utils_session.py +42 -0
- src/common/utils/utils_voice.py +42 -0
- src/common/version.py +33 -0
- src/config/__init__.py +0 -0
- src/config/config.py +927 -0
- src/config/config_base.py +308 -0
- src/config/config_upgrade_hooks.py +675 -0
- src/config/config_utils.py +166 -0
- src/config/default_model_config.py +131 -0
- src/config/file_watcher.py +238 -0
- src/config/legacy_migration.py +424 -0
- src/config/legacy_upgrade_confirmation.py +225 -0
- src/config/model_configs.py +548 -0
- src/config/official_configs.py +6166 -0
- src/config/startup_bindings.py +201 -0
- src/core/__init__.py +6 -0
- src/core/announcement_manager.py +120 -0
- src/core/config_types.py +273 -0
- src/core/event_bus.py +225 -0
- src/core/local_operator.py +98 -0
- src/core/tooling.py +420 -0
- src/core/types.py +423 -0
- src/emoji_system/emoji_cache_cleanup.py +326 -0
- src/emoji_system/emoji_manager.py +1314 -0
- src/emoji_system/maisaka_tool.py +286 -0
- src/learners/expression_learner.py +927 -0
- src/learners/expression_review_store.py +216 -0
- src/learners/expression_style_utils.py +32 -0
- src/learners/expression_utils.py +299 -0
- src/learners/jargon_learner.py +873 -0
- src/learners/jargon_miner.py +885 -0
- src/learners/weighted_sampling.py +75 -0
- src/llm_models/LICENSE +21 -0
- src/llm_models/__init__.py +0 -0
- src/llm_models/exceptions.py +140 -0
- src/llm_models/generation_diagnostics.py +155 -0
- src/llm_models/image_normalizer.py +128 -0
- src/llm_models/model_client/__init__.py +29 -0
- src/llm_models/model_client/adapter_base.py +362 -0
- src/llm_models/model_client/base_client.py +678 -0
- src/llm_models/model_client/embedding_rate_limit.py +55 -0
- src/llm_models/model_client/gemini_client.py +1379 -0
- src/llm_models/model_client/image_embedding_protocols.py +397 -0
- src/llm_models/model_client/openai_client.py +2200 -0
- src/llm_models/model_client/openai_responses_client.py +817 -0
- src/llm_models/model_client/plugin_client.py +307 -0
- src/llm_models/openai_compat.py +165 -0
- src/llm_models/payload_content/__init__.py +51 -0
- src/llm_models/payload_content/context_item.py +699 -0
- src/llm_models/payload_content/context_protocol.py +243 -0
- src/llm_models/payload_content/native_tool.py +18 -0
- src/llm_models/payload_content/resp_format.py +239 -0
- src/llm_models/payload_content/tool_option.py +547 -0
- src/llm_models/request_snapshot.py +1311 -0
- src/llm_models/utils.py +276 -0
- src/llm_models/utils_model.py +1604 -0
- src/main.py +300 -0
- src/maisaka/attention_drift.py +116 -0
- src/maisaka/builtin_tool/__init__.py +236 -0
- src/maisaka/builtin_tool/context.py +530 -0
- src/maisaka/builtin_tool/fetch_history.py +62 -0
- src/maisaka/builtin_tool/provider.py +74 -0
- src/maisaka/builtin_tool/query_image_memory.py +103 -0
- src/maisaka/builtin_tool/query_memory.py +335 -0
- src/maisaka/builtin_tool/query_person_profile.py +152 -0
- src/maisaka/builtin_tool/read_tool_result.py +142 -0
- src/maisaka/builtin_tool/reply.py +695 -0
- src/maisaka/builtin_tool/send_emoji.py +543 -0
- src/maisaka/builtin_tool/show_emoji_list.py +76 -0
- src/maisaka/builtin_tool/subagent.py +549 -0
- src/maisaka/builtin_tool/switch_chat.py +78 -0
- src/maisaka/builtin_tool/tool_search.py +92 -0
- src/maisaka/builtin_tool/view_forward_message.py +153 -0
- src/maisaka/builtin_tool/wait.py +77 -0
- src/maisaka/chat_loop_service.py +1579 -0
- src/maisaka/context/__init__.py +1 -0
- src/maisaka/context/clear_context.py +42 -0
- src/maisaka/context/emoji_candidates.py +36 -0
- src/maisaka/context/history.py +384 -0
- src/maisaka/context/image_attachment.py +52 -0
- src/maisaka/context/message_adapter.py +126 -0
- src/maisaka/context/message_id_alias.py +52 -0
- src/maisaka/context/messages.py +825 -0
- src/maisaka/context/planner_messages.py +199 -0
- src/maisaka/context/post_processor.py +204 -0
- src/maisaka/context/usage.py +141 -0
- src/maisaka/display/__init__.py +25 -0
- src/maisaka/display/display_utils.py +144 -0
- src/maisaka/display/preview_path_utils.py +64 -0
- src/maisaka/display/prompt_cli_renderer.py +1110 -0
- src/maisaka/display/prompt_preview_logger.py +603 -0
- src/maisaka/display/runtime_mixin.py +709 -0
- src/maisaka/display/stage_status_board.py +129 -0
- src/maisaka/focus/__init__.py +10 -0
- src/maisaka/focus/manager.py +442 -0
- src/maisaka/focus/runtime_mixin.py +753 -0
- src/maisaka/jargon_context_matcher.py +363 -0
- src/maisaka/memory/__init__.py +1 -0
- src/maisaka/memory/heuristic_injector.py +393 -0
- src/maisaka/memory/image_injector.py +81 -0
- src/maisaka/memory/mid_term.py +995 -0
- src/maisaka/memory/person_profile.py +280 -0
- src/maisaka/mode_policy.py +28 -0
- src/maisaka/monitor/__init__.py +1 -0
- src/maisaka/monitor/event_store.py +351 -0
- src/maisaka/monitor/events.py +795 -0
- src/maisaka/monitor/message_payload.py +172 -0
- src/maisaka/monitor/planner_delta.py +77 -0
- src/maisaka/monitor/tool_media.py +58 -0
- src/maisaka/planner_reply_guard.py +69 -0
- src/maisaka/reasoning_engine.py +2186 -0
- src/maisaka/reply_effect/__init__.py +18 -0
- src/maisaka/reply_effect/image_utils.py +100 -0
- src/maisaka/reply_effect/judge.py +382 -0
- src/maisaka/reply_effect/models.py +185 -0
- src/maisaka/reply_effect/path_utils.py +24 -0
- src/maisaka/reply_effect/quote_utils.py +32 -0
- src/maisaka/reply_effect/scoring.py +205 -0
- src/maisaka/reply_effect/storage.py +380 -0
- src/maisaka/reply_effect/tracker.py +568 -0
- src/maisaka/runtime.py +2218 -0
- src/maisaka/subagent_tasks.py +171 -0
- src/maisaka/tool_result_condenser.py +143 -0
- src/maisaka/tool_result_store.py +57 -0
- src/maisaka/turn_trigger/__init__.py +9 -0
- src/maisaka/turn_trigger/dynamic_gate.py +211 -0
- src/maisaka/turn_trigger/gates.py +252 -0
- src/maisaka/turn_trigger/idle_backoff.py +93 -0
- src/maisaka/turn_trigger/reply_likelihood.py +88 -0
- src/maisaka/turn_trigger/scheduler.py +113 -0
- src/maisaka/utils/__init__.py +1 -0
- src/maisaka/utils/tool_post_execution.py +76 -0
- src/maisaka/utils/tool_record_payload.py +155 -0
- src/maisaka/visual/__init__.py +1 -0
- src/maisaka/visual/chat_history_refresher.py +303 -0
- src/maisaka/visual/message_limiter.py +69 -0
- src/maisaka/visual/mode_utils.py +33 -0
- src/manager/async_task_manager.py +193 -0
- src/manager/local_store_manager.py +126 -0
- src/mcp_module/__init__.py +9 -0
- src/mcp_module/config.py +174 -0
- src/mcp_module/connection.py +726 -0
- src/mcp_module/hooks.py +20 -0
- src/mcp_module/host_llm_bridge.py +654 -0
- src/mcp_module/manager.py +680 -0
- src/mcp_module/models.py +437 -0
- src/mcp_module/provider.py +62 -0
- src/mcp_module/service.py +308 -0
- src/mcp_module/stdio_filter.py +177 -0
- src/person_info/__init__.py +0 -0
- src/person_info/person_info.py +600 -0
- src/platform_io/__init__.py +39 -0
- src/platform_io/adapter_policy.py +839 -0
- src/platform_io/avatar.py +197 -0
- src/platform_io/dedupe.py +133 -0
- src/platform_io/drivers/__init__.py +11 -0
- src/platform_io/drivers/base.py +104 -0
- src/platform_io/drivers/legacy_driver.py +92 -0
- src/platform_io/drivers/plugin_driver.py +213 -0
- src/platform_io/manager.py +716 -0
- src/platform_io/outbound_tracker.py +286 -0
- src/platform_io/registry.py +70 -0
- src/platform_io/route_key_factory.py +174 -0
- src/platform_io/routing.py +141 -0
- src/platform_io/types.py +265 -0
- src/plugin_runtime/__init__.py +60 -0
- src/plugin_runtime/avatar_provider.py +64 -0
- src/plugin_runtime/capabilities/__init__.py +11 -0
- src/plugin_runtime/capabilities/components.py +977 -0
- src/plugin_runtime/capabilities/core.py +868 -0
- src/plugin_runtime/capabilities/data.py +1415 -0
- src/plugin_runtime/capabilities/registry.py +116 -0
- src/plugin_runtime/capabilities/render.py +131 -0
- src/plugin_runtime/compat_policy.py +47 -0
- src/plugin_runtime/component_query.py +1119 -0
- src/plugin_runtime/dependency_pipeline.py +547 -0
- src/plugin_runtime/docker_layout_migration.py +275 -0
- src/plugin_runtime/hook_catalog.py +52 -0
- src/plugin_runtime/hook_payloads.py +197 -0
- src/plugin_runtime/hook_schema_utils.py +31 -0
- src/plugin_runtime/host/__init__.py +1 -0
- src/plugin_runtime/host/api_registry.py +353 -0
- src/plugin_runtime/host/authorization.py +67 -0
- src/plugin_runtime/host/capability_service.py +91 -0
- src/plugin_runtime/host/circuit_breaker.py +243 -0
- src/plugin_runtime/host/component_registry.py +1336 -0
- src/plugin_runtime/host/component_timeout.py +32 -0
- src/plugin_runtime/host/event_dispatcher.py +228 -0
- src/plugin_runtime/host/hook_dispatcher.py +711 -0
- src/plugin_runtime/host/hook_spec_registry.py +190 -0
- src/plugin_runtime/host/logger_bridge.py +64 -0
- src/plugin_runtime/host/message_gateway.py +116 -0
- src/plugin_runtime/host/message_utils.py +572 -0
- src/plugin_runtime/host/reply_extension_schema.py +65 -0
- src/plugin_runtime/host/reply_extensions.py +204 -0
- src/plugin_runtime/host/rpc_server.py +509 -0
- src/plugin_runtime/host/supervisor.py +2269 -0
- src/plugin_runtime/integration.py +2063 -0
- src/plugin_runtime/local_sdk.py +147 -0
- src/plugin_runtime/protocol/__init__.py +1 -0
- src/plugin_runtime/protocol/codec.py +71 -0
- src/plugin_runtime/protocol/envelope.py +600 -0
- src/plugin_runtime/protocol/errors.py +77 -0
- src/plugin_runtime/runner/__init__.py +1 -0
- src/plugin_runtime/runner/log_handler.py +239 -0
- src/plugin_runtime/runner/manifest_validator.py +1517 -0
- src/plugin_runtime/runner/plugin_loader.py +711 -0
- src/plugin_runtime/runner/plugin_paths.py +59 -0
- src/plugin_runtime/runner/rpc_client.py +336 -0
- src/plugin_runtime/runner/runner_main.py +2670 -0
- src/plugin_runtime/tool_provider.py +58 -0
- src/plugin_runtime/transport/__init__.py +1 -0
- src/plugin_runtime/transport/base.py +116 -0
- src/plugin_runtime/transport/factory.py +57 -0
- src/plugin_runtime/transport/named_pipe.py +206 -0
- src/plugin_runtime/transport/tcp.py +62 -0
- src/plugin_runtime/transport/uds.py +133 -0
- src/plugin_runtime/update_compatibility_notice.py +303 -0
- src/plugin_runtime/upload_store.py +173 -0
- src/plugin_runtime/webui_schema.py +571 -0
- src/plugins/__init__.py +1 -0
- src/plugins/built_in/plugin_management/_manifest.json +48 -0
- src/plugins/built_in/plugin_management/plugin.py +665 -0
- src/prompt/prompt_manager.py +440 -0
- src/runtime.py +688 -0
- src/services/__init__.py +7 -0
- src/services/bot_account_service.py +327 -0
- src/services/database_service.py +203 -0
- src/services/embedding_service.py +271 -0
- src/services/expression_vector_service.py +106 -0
- src/services/html_render_service.py +945 -0
- src/services/image_path_maintenance_service.py +454 -0
- src/services/image_writeback_journal.py +98 -0
- src/services/llm_cache_stats.py +1521 -0
- src/services/llm_service.py +799 -0
- src/services/memory_flow_service.py +1019 -0
- src/services/memory_service.py +528 -0
- src/services/message_service.py +295 -0
- src/services/message_word_frequency_service.py +595 -0
- src/services/news_service.py +123 -0
- src/services/person_fact_reverification.py +90 -0
- src/services/person_fact_verifier.py +79 -0
- src/services/send_service.py +1483 -0
- src/services/service_task_resolver.py +59 -0
- src/services/statistics_aggregation_service.py +349 -0
- src/services/statistics_service.py +850 -0
- src/services/telemetry_stats_service.py +294 -0
- src/services/tool_record_cleanup_service.py +205 -0
- src/webui/app.py +374 -0
- src/webui/config_schema.py +481 -0
- src/webui/core/__init__.py +32 -0
- src/webui/core/auth.py +153 -0
- src/webui/core/rate_limiter.py +247 -0
- src/webui/core/security.py +361 -0
- src/webui/dependencies.py +70 -0
- src/webui/logs_ws.py +195 -0
- src/webui/middleware/__init__.py +17 -0
- src/webui/middleware/anti_crawler.py +824 -0
- src/webui/middleware/compression.py +88 -0
- src/webui/routers/__init__.py +33 -0
- src/webui/routers/avatar.py +250 -0
- src/webui/routers/bot_accounts.py +103 -0
- src/webui/routers/chat/__init__.py +18 -0
- src/webui/routers/chat/routes.py +1715 -0
- src/webui/routers/chat/serializers.py +248 -0
- src/webui/routers/chat/service.py +1489 -0
- src/webui/routers/config.py +1964 -0
- src/webui/routers/data_transfer.py +769 -0
- src/webui/routers/emoji/__init__.py +3 -0
- src/webui/routers/emoji/routes.py +1230 -0
- src/webui/routers/emoji/schemas.py +182 -0
- src/webui/routers/emoji/support.py +143 -0
- src/webui/routers/expression.py +2298 -0
- src/webui/routers/jargon.py +1319 -0
- src/webui/routers/mcp.py +99 -0
- src/webui/routers/memory.py +5589 -0
- src/webui/routers/model.py +910 -0
- src/webui/routers/news.py +23 -0
- src/webui/routers/person.py +427 -0
- src/webui/routers/plugin/__init__.py +31 -0
- src/webui/routers/plugin/catalog.py +218 -0
- src/webui/routers/plugin/config_routes.py +592 -0
- src/webui/routers/plugin/icon_routes.py +71 -0
- src/webui/routers/plugin/management.py +908 -0
- src/webui/routers/plugin/marketplace.py +116 -0
- src/webui/routers/plugin/progress.py +172 -0
- src/webui/routers/plugin/release_install.py +313 -0
- src/webui/routers/plugin/releases.py +181 -0
- src/webui/routers/plugin/runtime_routes.py +243 -0
- src/webui/routers/plugin/schemas.py +131 -0
- src/webui/routers/plugin/stats_proxy.py +127 -0
- src/webui/routers/plugin/support.py +366 -0
- src/webui/routers/plugin/webui_extensions.py +346 -0
- src/webui/routers/plugin/zip_install.py +125 -0
- src/webui/routers/reasoning_process.py +2326 -0
- src/webui/routers/reply_effects.py +926 -0
- src/webui/routers/search.py +249 -0
- src/webui/routers/self_update.py +194 -0
- src/webui/routers/statistics.py +61 -0
- src/webui/routers/system.py +1790 -0
- src/webui/routers/user_emoji.py +168 -0
- src/webui/routers/websocket/__init__.py +7 -0
- src/webui/routers/websocket/auth.py +104 -0
- src/webui/routers/websocket/manager.py +373 -0
- src/webui/routers/websocket/plugin_runtime.py +54 -0
- src/webui/routers/websocket/unified.py +761 -0
- src/webui/routes.py +416 -0
- src/webui/schemas/__init__.py +18 -0
- src/webui/schemas/news.py +24 -0
- src/webui/schemas/statistics.py +173 -0
- src/webui/services/__init__.py +1 -0
- src/webui/services/ai_search_agent.py +509 -0
- src/webui/services/ai_search_documents.py +329 -0
- src/webui/services/ai_search_grounding.py +103 -0
- src/webui/services/ai_search_local_config.py +129 -0
- src/webui/services/ai_search_models.py +98 -0
- src/webui/services/git_mirror_service.py +1024 -0
- src/webui/services/plugin_market_service.py +56 -0
- src/webui/utils/__init__.py +1 -0
- src/webui/utils/http_client.py +27 -0
- src/webui/utils/network_security.py +133 -0
- src/webui/utils/toml_utils.py +124 -0
- src/webui/version_compatibility.py +180 -0
- 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"]
|