AstrBot 4.10.2__py3-none-any.whl → 4.10.4__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (84) hide show
  1. astrbot/builtin_stars/astrbot/long_term_memory.py +186 -0
  2. astrbot/builtin_stars/astrbot/main.py +120 -0
  3. astrbot/builtin_stars/astrbot/metadata.yaml +4 -0
  4. astrbot/builtin_stars/astrbot/process_llm_request.py +245 -0
  5. astrbot/builtin_stars/builtin_commands/commands/__init__.py +31 -0
  6. astrbot/builtin_stars/builtin_commands/commands/admin.py +77 -0
  7. astrbot/builtin_stars/builtin_commands/commands/alter_cmd.py +173 -0
  8. astrbot/builtin_stars/builtin_commands/commands/conversation.py +366 -0
  9. astrbot/builtin_stars/builtin_commands/commands/help.py +88 -0
  10. astrbot/builtin_stars/builtin_commands/commands/llm.py +20 -0
  11. astrbot/builtin_stars/builtin_commands/commands/persona.py +142 -0
  12. astrbot/builtin_stars/builtin_commands/commands/plugin.py +120 -0
  13. astrbot/builtin_stars/builtin_commands/commands/provider.py +329 -0
  14. astrbot/builtin_stars/builtin_commands/commands/setunset.py +36 -0
  15. astrbot/builtin_stars/builtin_commands/commands/sid.py +36 -0
  16. astrbot/builtin_stars/builtin_commands/commands/t2i.py +23 -0
  17. astrbot/builtin_stars/builtin_commands/commands/tool.py +31 -0
  18. astrbot/builtin_stars/builtin_commands/commands/tts.py +36 -0
  19. astrbot/builtin_stars/builtin_commands/commands/utils/rst_scene.py +26 -0
  20. astrbot/builtin_stars/builtin_commands/main.py +237 -0
  21. astrbot/builtin_stars/builtin_commands/metadata.yaml +4 -0
  22. astrbot/builtin_stars/python_interpreter/main.py +536 -0
  23. astrbot/builtin_stars/python_interpreter/metadata.yaml +4 -0
  24. astrbot/builtin_stars/python_interpreter/requirements.txt +1 -0
  25. astrbot/builtin_stars/python_interpreter/shared/api.py +22 -0
  26. astrbot/builtin_stars/reminder/main.py +266 -0
  27. astrbot/builtin_stars/reminder/metadata.yaml +4 -0
  28. astrbot/builtin_stars/session_controller/main.py +114 -0
  29. astrbot/builtin_stars/session_controller/metadata.yaml +5 -0
  30. astrbot/builtin_stars/web_searcher/engines/__init__.py +111 -0
  31. astrbot/builtin_stars/web_searcher/engines/bing.py +30 -0
  32. astrbot/builtin_stars/web_searcher/engines/sogo.py +52 -0
  33. astrbot/builtin_stars/web_searcher/main.py +436 -0
  34. astrbot/builtin_stars/web_searcher/metadata.yaml +4 -0
  35. astrbot/cli/__init__.py +1 -1
  36. astrbot/core/agent/message.py +32 -1
  37. astrbot/core/agent/runners/tool_loop_agent_runner.py +26 -8
  38. astrbot/core/astr_agent_hooks.py +6 -0
  39. astrbot/core/backup/__init__.py +26 -0
  40. astrbot/core/backup/constants.py +77 -0
  41. astrbot/core/backup/exporter.py +477 -0
  42. astrbot/core/backup/importer.py +761 -0
  43. astrbot/core/config/astrbot_config.py +2 -0
  44. astrbot/core/config/default.py +47 -6
  45. astrbot/core/knowledge_base/chunking/recursive.py +10 -2
  46. astrbot/core/log.py +1 -1
  47. astrbot/core/pipeline/process_stage/method/agent_sub_stages/internal.py +184 -174
  48. astrbot/core/pipeline/result_decorate/stage.py +65 -57
  49. astrbot/core/pipeline/waking_check/stage.py +31 -3
  50. astrbot/core/platform/sources/aiocqhttp/aiocqhttp_platform_adapter.py +15 -29
  51. astrbot/core/platform/sources/dingtalk/dingtalk_adapter.py +1 -6
  52. astrbot/core/platform/sources/dingtalk/dingtalk_event.py +15 -1
  53. astrbot/core/platform/sources/lark/lark_adapter.py +2 -10
  54. astrbot/core/platform/sources/misskey/misskey_adapter.py +0 -5
  55. astrbot/core/platform/sources/misskey/misskey_utils.py +0 -3
  56. astrbot/core/platform/sources/qqofficial/qqofficial_platform_adapter.py +4 -9
  57. astrbot/core/platform/sources/qqofficial_webhook/qo_webhook_adapter.py +4 -9
  58. astrbot/core/platform/sources/satori/satori_adapter.py +6 -1
  59. astrbot/core/platform/sources/slack/slack_adapter.py +3 -6
  60. astrbot/core/platform/sources/webchat/webchat_adapter.py +0 -1
  61. astrbot/core/platform/sources/wechatpadpro/wechatpadpro_adapter.py +3 -5
  62. astrbot/core/provider/entities.py +41 -10
  63. astrbot/core/provider/provider.py +3 -1
  64. astrbot/core/provider/sources/anthropic_source.py +140 -30
  65. astrbot/core/provider/sources/fishaudio_tts_api_source.py +14 -6
  66. astrbot/core/provider/sources/gemini_source.py +112 -29
  67. astrbot/core/provider/sources/minimax_tts_api_source.py +4 -1
  68. astrbot/core/provider/sources/openai_source.py +93 -56
  69. astrbot/core/provider/sources/xai_source.py +29 -0
  70. astrbot/core/provider/sources/xinference_stt_provider.py +24 -12
  71. astrbot/core/star/context.py +1 -1
  72. astrbot/core/star/star_manager.py +52 -13
  73. astrbot/core/utils/astrbot_path.py +34 -0
  74. astrbot/core/utils/pip_installer.py +20 -1
  75. astrbot/dashboard/routes/__init__.py +2 -0
  76. astrbot/dashboard/routes/backup.py +1093 -0
  77. astrbot/dashboard/routes/config.py +45 -0
  78. astrbot/dashboard/routes/log.py +44 -10
  79. astrbot/dashboard/server.py +9 -1
  80. {astrbot-4.10.2.dist-info → astrbot-4.10.4.dist-info}/METADATA +1 -1
  81. {astrbot-4.10.2.dist-info → astrbot-4.10.4.dist-info}/RECORD +84 -44
  82. {astrbot-4.10.2.dist-info → astrbot-4.10.4.dist-info}/WHEEL +0 -0
  83. {astrbot-4.10.2.dist-info → astrbot-4.10.4.dist-info}/entry_points.txt +0 -0
  84. {astrbot-4.10.2.dist-info → astrbot-4.10.4.dist-info}/licenses/LICENSE +0 -0
@@ -14,6 +14,7 @@ import astrbot.core.message.components as Comp
14
14
  from astrbot import logger
15
15
  from astrbot.core.agent.message import (
16
16
  AssistantMessageSegment,
17
+ ContentPart,
17
18
  ToolCall,
18
19
  ToolCallMessageSegment,
19
20
  )
@@ -92,6 +93,8 @@ class ProviderRequest:
92
93
  """会话 ID"""
93
94
  image_urls: list[str] = field(default_factory=list)
94
95
  """图片 URL 列表"""
96
+ extra_user_content_parts: list[ContentPart] = field(default_factory=list)
97
+ """额外的用户消息内容部分列表,用于在用户消息后添加额外的内容块(如系统提醒、指令等)。支持 dict 或 ContentPart 对象"""
95
98
  func_tool: ToolSet | None = None
96
99
  """可用的函数工具"""
97
100
  contexts: list[dict] = field(default_factory=list)
@@ -166,13 +169,23 @@ class ProviderRequest:
166
169
 
167
170
  async def assemble_context(self) -> dict:
168
171
  """将请求(prompt 和 image_urls)包装成 OpenAI 的消息格式。"""
172
+ # 构建内容块列表
173
+ content_blocks = []
174
+
175
+ # 1. 用户原始发言(OpenAI 建议:用户发言在前)
176
+ if self.prompt and self.prompt.strip():
177
+ content_blocks.append({"type": "text", "text": self.prompt})
178
+ elif self.image_urls:
179
+ # 如果没有文本但有图片,添加占位文本
180
+ content_blocks.append({"type": "text", "text": "[图片]"})
181
+
182
+ # 2. 额外的内容块(系统提醒、指令等)
183
+ if self.extra_user_content_parts:
184
+ for part in self.extra_user_content_parts:
185
+ content_blocks.append(part.model_dump())
186
+
187
+ # 3. 图片内容
169
188
  if self.image_urls:
170
- user_content = {
171
- "role": "user",
172
- "content": [
173
- {"type": "text", "text": self.prompt if self.prompt else "[图片]"},
174
- ],
175
- }
176
189
  for image_url in self.image_urls:
177
190
  if image_url.startswith("http"):
178
191
  image_path = await download_image_by_url(image_url)
@@ -185,11 +198,21 @@ class ProviderRequest:
185
198
  if not image_data:
186
199
  logger.warning(f"图片 {image_url} 得到的结果为空,将忽略。")
187
200
  continue
188
- user_content["content"].append(
201
+ content_blocks.append(
189
202
  {"type": "image_url", "image_url": {"url": image_data}},
190
203
  )
191
- return user_content
192
- return {"role": "user", "content": self.prompt}
204
+
205
+ # 只有当只有一个来自 prompt 的文本块且没有额外内容块时,才降级为简单格式以保持向后兼容
206
+ if (
207
+ len(content_blocks) == 1
208
+ and content_blocks[0]["type"] == "text"
209
+ and not self.extra_user_content_parts
210
+ and not self.image_urls
211
+ ):
212
+ return {"role": "user", "content": content_blocks[0]["text"]}
213
+
214
+ # 否则返回多模态格式
215
+ return {"role": "user", "content": content_blocks}
193
216
 
194
217
  async def _encode_image_bs64(self, image_url: str) -> str:
195
218
  """将图片转换为 base64"""
@@ -249,6 +272,8 @@ class LLMResponse:
249
272
  """Tool call extra content. tool_call_id -> extra_content dict"""
250
273
  reasoning_content: str = ""
251
274
  """The reasoning content extracted from the LLM, if any."""
275
+ reasoning_signature: str | None = None
276
+ """The signature of the reasoning content, if any."""
252
277
 
253
278
  raw_completion: (
254
279
  ChatCompletion | GenerateContentResponse | AnthropicMessage | None
@@ -269,12 +294,14 @@ class LLMResponse:
269
294
  def __init__(
270
295
  self,
271
296
  role: str,
272
- completion_text: str = "",
297
+ completion_text: str | None = None,
273
298
  result_chain: MessageChain | None = None,
274
299
  tools_call_args: list[dict[str, Any]] | None = None,
275
300
  tools_call_name: list[str] | None = None,
276
301
  tools_call_ids: list[str] | None = None,
277
302
  tools_call_extra_content: dict[str, dict[str, Any]] | None = None,
303
+ reasoning_content: str | None = None,
304
+ reasoning_signature: str | None = None,
278
305
  raw_completion: ChatCompletion
279
306
  | GenerateContentResponse
280
307
  | AnthropicMessage
@@ -294,6 +321,8 @@ class LLMResponse:
294
321
  raw_completion (ChatCompletion, optional): 原始响应, OpenAI 格式. Defaults to None.
295
322
 
296
323
  """
324
+ if reasoning_content is None:
325
+ reasoning_content = ""
297
326
  if tools_call_args is None:
298
327
  tools_call_args = []
299
328
  if tools_call_name is None:
@@ -310,6 +339,8 @@ class LLMResponse:
310
339
  self.tools_call_name = tools_call_name
311
340
  self.tools_call_ids = tools_call_ids
312
341
  self.tools_call_extra_content = tools_call_extra_content
342
+ self.reasoning_content = reasoning_content
343
+ self.reasoning_signature = reasoning_signature
313
344
  self.raw_completion = raw_completion
314
345
  self.is_chunk = is_chunk
315
346
 
@@ -4,7 +4,7 @@ import os
4
4
  from collections.abc import AsyncGenerator
5
5
  from typing import TypeAlias, Union
6
6
 
7
- from astrbot.core.agent.message import Message
7
+ from astrbot.core.agent.message import ContentPart, Message
8
8
  from astrbot.core.agent.tool import ToolSet
9
9
  from astrbot.core.provider.entities import (
10
10
  LLMResponse,
@@ -103,6 +103,7 @@ class Provider(AbstractProvider):
103
103
  system_prompt: str | None = None,
104
104
  tool_calls_result: ToolCallsResult | list[ToolCallsResult] | None = None,
105
105
  model: str | None = None,
106
+ extra_user_content_parts: list[ContentPart] | None = None,
106
107
  **kwargs,
107
108
  ) -> LLMResponse:
108
109
  """获得 LLM 的文本对话结果。会使用当前的模型进行对话。
@@ -114,6 +115,7 @@ class Provider(AbstractProvider):
114
115
  tools: tool set
115
116
  contexts: 上下文,和 prompt 二选一使用
116
117
  tool_calls_result: 回传给 LLM 的工具调用结果。参考: https://platform.openai.com/docs/guides/function-calling
118
+ extra_user_content_parts: 额外的内容块列表,用于在用户消息后添加额外的文本块(如系统提醒、指令等)
117
119
  kwargs: 其他参数
118
120
 
119
121
  Notes:
@@ -11,6 +11,7 @@ from anthropic.types.usage import Usage
11
11
 
12
12
  from astrbot import logger
13
13
  from astrbot.api.provider import Provider
14
+ from astrbot.core.agent.message import ContentPart, ImageURLPart, TextPart
14
15
  from astrbot.core.provider.entities import LLMResponse, TokenUsage
15
16
  from astrbot.core.provider.func_tool_manager import ToolSet
16
17
  from astrbot.core.utils.io import download_image_by_url
@@ -47,6 +48,8 @@ class ProviderAnthropic(Provider):
47
48
  base_url=self.base_url,
48
49
  )
49
50
 
51
+ self.thinking_config = provider_config.get("anth_thinking_config", {})
52
+
50
53
  self.set_model(provider_config.get("model", "unknown"))
51
54
 
52
55
  def _prepare_payload(self, messages: list[dict]):
@@ -63,12 +66,33 @@ class ProviderAnthropic(Provider):
63
66
  new_messages = []
64
67
  for message in messages:
65
68
  if message["role"] == "system":
66
- system_prompt = message["content"]
69
+ system_prompt = message["content"] or "<empty system prompt>"
67
70
  elif message["role"] == "assistant":
68
71
  blocks = []
69
- if isinstance(message["content"], str):
72
+ reasoning_content = ""
73
+ thinking_signature = ""
74
+ if isinstance(message["content"], str) and message["content"].strip():
70
75
  blocks.append({"type": "text", "text": message["content"]})
71
- if "tool_calls" in message:
76
+ elif isinstance(message["content"], list):
77
+ for part in message["content"]:
78
+ if part.get("type") == "think":
79
+ # only pick the last think part for now
80
+ reasoning_content = part.get("think")
81
+ thinking_signature = part.get("encrypted")
82
+ else:
83
+ blocks.append(part)
84
+
85
+ if reasoning_content and thinking_signature:
86
+ blocks.insert(
87
+ 0,
88
+ {
89
+ "type": "thinking",
90
+ "thinking": reasoning_content,
91
+ "signature": thinking_signature,
92
+ },
93
+ )
94
+
95
+ if "tool_calls" in message and isinstance(message["tool_calls"], list):
72
96
  for tool_call in message["tool_calls"]:
73
97
  blocks.append( # noqa: PERF401
74
98
  {
@@ -99,7 +123,7 @@ class ProviderAnthropic(Provider):
99
123
  {
100
124
  "type": "tool_result",
101
125
  "tool_use_id": message["tool_call_id"],
102
- "content": message["content"],
126
+ "content": message["content"] or "<empty response>",
103
127
  },
104
128
  ],
105
129
  },
@@ -132,6 +156,14 @@ class ProviderAnthropic(Provider):
132
156
 
133
157
  extra_body = self.provider_config.get("custom_extra_body", {})
134
158
 
159
+ if "max_tokens" not in payloads:
160
+ payloads["max_tokens"] = 1024
161
+ if self.thinking_config.get("budget"):
162
+ payloads["thinking"] = {
163
+ "budget_tokens": self.thinking_config.get("budget"),
164
+ "type": "enabled",
165
+ }
166
+
135
167
  completion = await self.client.messages.create(
136
168
  **payloads, stream=False, extra_body=extra_body
137
169
  )
@@ -149,6 +181,11 @@ class ProviderAnthropic(Provider):
149
181
  completion_text = str(content_block.text).strip()
150
182
  llm_response.completion_text = completion_text
151
183
 
184
+ if content_block.type == "thinking":
185
+ reasoning_content = str(content_block.thinking).strip()
186
+ llm_response.reasoning_content = reasoning_content
187
+ llm_response.reasoning_signature = content_block.signature
188
+
152
189
  if content_block.type == "tool_use":
153
190
  llm_response.tools_call_args.append(content_block.input)
154
191
  llm_response.tools_call_name.append(content_block.name)
@@ -180,6 +217,16 @@ class ProviderAnthropic(Provider):
180
217
  id = None
181
218
  usage = TokenUsage()
182
219
  extra_body = self.provider_config.get("custom_extra_body", {})
220
+ reasoning_content = ""
221
+ reasoning_signature = ""
222
+
223
+ if "max_tokens" not in payloads:
224
+ payloads["max_tokens"] = 1024
225
+ if self.thinking_config.get("budget"):
226
+ payloads["thinking"] = {
227
+ "budget_tokens": self.thinking_config.get("budget"),
228
+ "type": "enabled",
229
+ }
183
230
 
184
231
  async with self.client.messages.stream(
185
232
  **payloads, extra_body=extra_body
@@ -219,6 +266,21 @@ class ProviderAnthropic(Provider):
219
266
  usage=usage,
220
267
  id=id,
221
268
  )
269
+ elif event.delta.type == "thinking_delta":
270
+ # 思考增量
271
+ reasoning = event.delta.thinking
272
+ if reasoning:
273
+ yield LLMResponse(
274
+ role="assistant",
275
+ reasoning_content=reasoning,
276
+ is_chunk=True,
277
+ usage=usage,
278
+ id=id,
279
+ reasoning_signature=reasoning_signature or None,
280
+ )
281
+ reasoning_content += reasoning
282
+ elif event.delta.type == "signature_delta":
283
+ reasoning_signature = event.delta.signature
222
284
  elif event.delta.type == "input_json_delta":
223
285
  # 工具调用参数增量
224
286
  if event.index in tool_use_buffer:
@@ -275,6 +337,8 @@ class ProviderAnthropic(Provider):
275
337
  is_chunk=False,
276
338
  usage=usage,
277
339
  id=id,
340
+ reasoning_content=reasoning_content,
341
+ reasoning_signature=reasoning_signature or None,
278
342
  )
279
343
 
280
344
  if final_tool_calls:
@@ -296,13 +360,16 @@ class ProviderAnthropic(Provider):
296
360
  system_prompt=None,
297
361
  tool_calls_result=None,
298
362
  model=None,
363
+ extra_user_content_parts=None,
299
364
  **kwargs,
300
365
  ) -> LLMResponse:
301
366
  if contexts is None:
302
367
  contexts = []
303
368
  new_record = None
304
369
  if prompt is not None:
305
- new_record = await self.assemble_context(prompt, image_urls)
370
+ new_record = await self.assemble_context(
371
+ prompt, image_urls, extra_user_content_parts
372
+ )
306
373
  context_query = self._ensure_message_to_dicts(contexts)
307
374
  if new_record:
308
375
  context_query.append(new_record)
@@ -342,21 +409,24 @@ class ProviderAnthropic(Provider):
342
409
 
343
410
  async def text_chat_stream(
344
411
  self,
345
- prompt,
412
+ prompt=None,
346
413
  session_id=None,
347
- image_urls=...,
414
+ image_urls=None,
348
415
  func_tool=None,
349
- contexts=...,
416
+ contexts=None,
350
417
  system_prompt=None,
351
418
  tool_calls_result=None,
352
419
  model=None,
420
+ extra_user_content_parts=None,
353
421
  **kwargs,
354
422
  ):
355
423
  if contexts is None:
356
424
  contexts = []
357
425
  new_record = None
358
426
  if prompt is not None:
359
- new_record = await self.assemble_context(prompt, image_urls)
427
+ new_record = await self.assemble_context(
428
+ prompt, image_urls, extra_user_content_parts
429
+ )
360
430
  context_query = self._ensure_message_to_dicts(contexts)
361
431
  if new_record:
362
432
  context_query.append(new_record)
@@ -388,15 +458,15 @@ class ProviderAnthropic(Provider):
388
458
  async for llm_response in self._query_stream(payloads, func_tool):
389
459
  yield llm_response
390
460
 
391
- async def assemble_context(self, text: str, image_urls: list[str] | None = None):
461
+ async def assemble_context(
462
+ self,
463
+ text: str,
464
+ image_urls: list[str] | None = None,
465
+ extra_user_content_parts: list[ContentPart] | None = None,
466
+ ):
392
467
  """组装上下文,支持文本和图片"""
393
- if not image_urls:
394
- return {"role": "user", "content": text}
395
468
 
396
- content = []
397
- content.append({"type": "text", "text": text})
398
-
399
- for image_url in image_urls:
469
+ async def resolve_image_url(image_url: str) -> dict | None:
400
470
  if image_url.startswith("http"):
401
471
  image_path = await download_image_by_url(image_url)
402
472
  image_data = await self.encode_image_bs64(image_path)
@@ -408,28 +478,68 @@ class ProviderAnthropic(Provider):
408
478
 
409
479
  if not image_data:
410
480
  logger.warning(f"图片 {image_url} 得到的结果为空,将忽略。")
411
- continue
481
+ return None
412
482
 
413
483
  # Get mime type for the image
414
484
  mime_type, _ = guess_type(image_url)
415
485
  if not mime_type:
416
486
  mime_type = "image/jpeg" # Default to JPEG if can't determine
417
487
 
418
- content.append(
419
- {
420
- "type": "image",
421
- "source": {
422
- "type": "base64",
423
- "media_type": mime_type,
424
- "data": (
425
- image_data.split("base64,")[1]
426
- if "base64," in image_data
427
- else image_data
428
- ),
429
- },
488
+ return {
489
+ "type": "image",
490
+ "source": {
491
+ "type": "base64",
492
+ "media_type": mime_type,
493
+ "data": (
494
+ image_data.split("base64,")[1]
495
+ if "base64," in image_data
496
+ else image_data
497
+ ),
430
498
  },
431
- )
499
+ }
500
+
501
+ content = []
432
502
 
503
+ # 1. 用户原始发言(OpenAI 建议:用户发言在前)
504
+ if text:
505
+ content.append({"type": "text", "text": text})
506
+ elif image_urls:
507
+ # 如果没有文本但有图片,添加占位文本
508
+ content.append({"type": "text", "text": "[图片]"})
509
+ elif extra_user_content_parts:
510
+ # 如果只有额外内容块,也需要添加占位文本
511
+ content.append({"type": "text", "text": " "})
512
+
513
+ # 2. 额外的内容块(系统提醒、指令等)
514
+ if extra_user_content_parts:
515
+ for block in extra_user_content_parts:
516
+ if isinstance(block, TextPart):
517
+ content.append({"type": "text", "text": block.text})
518
+ elif isinstance(block, ImageURLPart):
519
+ image_dict = await resolve_image_url(block.image_url.url)
520
+ if image_dict:
521
+ content.append(image_dict)
522
+ else:
523
+ raise ValueError(f"不支持的额外内容块类型: {type(block)}")
524
+
525
+ # 3. 图片内容
526
+ if image_urls:
527
+ for image_url in image_urls:
528
+ image_dict = await resolve_image_url(image_url)
529
+ if image_dict:
530
+ content.append(image_dict)
531
+
532
+ # 如果只有主文本且没有额外内容块和图片,返回简单格式以保持向后兼容
533
+ if (
534
+ text
535
+ and not extra_user_content_parts
536
+ and not image_urls
537
+ and len(content) == 1
538
+ and content[0]["type"] == "text"
539
+ ):
540
+ return {"role": "user", "content": content[0]["text"]}
541
+
542
+ # 否则返回多模态格式
433
543
  return {"role": "user", "content": content}
434
544
 
435
545
  async def encode_image_bs64(self, image_url: str) -> str:
@@ -56,10 +56,14 @@ class ProviderFishAudioTTSAPI(TTSProvider):
56
56
  "api_base",
57
57
  "https://api.fish-audio.cn/v1",
58
58
  )
59
+ try:
60
+ self.timeout: int = int(provider_config.get("timeout", 20))
61
+ except ValueError:
62
+ self.timeout = 20
59
63
  self.headers = {
60
64
  "Authorization": f"Bearer {self.chosen_api_key}",
61
65
  }
62
- self.set_model(provider_config["model"])
66
+ self.set_model(provider_config.get("model", None))
63
67
 
64
68
  async def _get_reference_id_by_character(self, character: str) -> str | None:
65
69
  """获取角色的reference_id
@@ -135,17 +139,21 @@ class ProviderFishAudioTTSAPI(TTSProvider):
135
139
  path = os.path.join(temp_dir, f"fishaudio_tts_api_{uuid.uuid4()}.wav")
136
140
  self.headers["content-type"] = "application/msgpack"
137
141
  request = await self._generate_request(text)
138
- async with AsyncClient(base_url=self.api_base).stream(
142
+ async with AsyncClient(base_url=self.api_base, timeout=self.timeout).stream(
139
143
  "POST",
140
144
  "/tts",
141
145
  headers=self.headers,
142
146
  content=ormsgpack.packb(request, option=ormsgpack.OPT_SERIALIZE_PYDANTIC),
143
147
  ) as response:
144
- if response.headers["content-type"] == "audio/wav":
148
+ if response.status_code == 200 and response.headers.get(
149
+ "content-type", ""
150
+ ).startswith("audio/"):
145
151
  with open(path, "wb") as f:
146
152
  async for chunk in response.aiter_bytes():
147
153
  f.write(chunk)
148
154
  return path
149
- body = await response.aread()
150
- text = body.decode("utf-8", errors="replace")
151
- raise Exception(f"Fish Audio API请求失败: {text}")
155
+ error_bytes = await response.aread()
156
+ error_text = error_bytes.decode("utf-8", errors="replace")[:1024]
157
+ raise Exception(
158
+ f"Fish Audio API请求失败: 状态码 {response.status_code}, 响应内容: {error_text}"
159
+ )