AstrBot 4.9.2__py3-none-any.whl → 4.10.0__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 (43) hide show
  1. astrbot/cli/__init__.py +1 -1
  2. astrbot/core/agent/message.py +6 -4
  3. astrbot/core/agent/response.py +22 -1
  4. astrbot/core/agent/run_context.py +1 -1
  5. astrbot/core/agent/runners/tool_loop_agent_runner.py +99 -20
  6. astrbot/core/astr_agent_context.py +3 -1
  7. astrbot/core/astr_agent_run_util.py +42 -3
  8. astrbot/core/astr_agent_tool_exec.py +34 -4
  9. astrbot/core/config/default.py +127 -184
  10. astrbot/core/core_lifecycle.py +3 -0
  11. astrbot/core/db/__init__.py +72 -0
  12. astrbot/core/db/po.py +59 -0
  13. astrbot/core/db/sqlite.py +240 -0
  14. astrbot/core/message/components.py +4 -5
  15. astrbot/core/pipeline/process_stage/method/agent_sub_stages/internal.py +6 -1
  16. astrbot/core/pipeline/respond/stage.py +1 -1
  17. astrbot/core/platform/sources/telegram/tg_event.py +9 -0
  18. astrbot/core/platform/sources/webchat/webchat_event.py +22 -18
  19. astrbot/core/provider/entities.py +41 -0
  20. astrbot/core/provider/manager.py +203 -93
  21. astrbot/core/provider/sources/anthropic_source.py +55 -11
  22. astrbot/core/provider/sources/gemini_source.py +84 -33
  23. astrbot/core/provider/sources/openai_source.py +21 -6
  24. astrbot/core/star/command_management.py +449 -0
  25. astrbot/core/star/context.py +4 -0
  26. astrbot/core/star/filter/command.py +1 -0
  27. astrbot/core/star/filter/command_group.py +1 -0
  28. astrbot/core/star/star_handler.py +4 -0
  29. astrbot/core/star/star_manager.py +2 -0
  30. astrbot/core/utils/llm_metadata.py +63 -0
  31. astrbot/core/utils/migra_helper.py +93 -0
  32. astrbot/dashboard/routes/__init__.py +2 -0
  33. astrbot/dashboard/routes/chat.py +56 -13
  34. astrbot/dashboard/routes/command.py +82 -0
  35. astrbot/dashboard/routes/config.py +291 -33
  36. astrbot/dashboard/routes/stat.py +96 -0
  37. astrbot/dashboard/routes/tools.py +20 -4
  38. astrbot/dashboard/server.py +1 -0
  39. {astrbot-4.9.2.dist-info → astrbot-4.10.0.dist-info}/METADATA +2 -2
  40. {astrbot-4.9.2.dist-info → astrbot-4.10.0.dist-info}/RECORD +43 -40
  41. {astrbot-4.9.2.dist-info → astrbot-4.10.0.dist-info}/WHEEL +0 -0
  42. {astrbot-4.9.2.dist-info → astrbot-4.10.0.dist-info}/entry_points.txt +0 -0
  43. {astrbot-4.9.2.dist-info → astrbot-4.10.0.dist-info}/licenses/LICENSE +0 -0
@@ -0,0 +1,449 @@
1
+ from __future__ import annotations
2
+
3
+ from collections import defaultdict
4
+ from dataclasses import dataclass, field
5
+ from typing import Any
6
+
7
+ from astrbot.core import db_helper
8
+ from astrbot.core.db.po import CommandConfig
9
+ from astrbot.core.star.filter.command import CommandFilter
10
+ from astrbot.core.star.filter.command_group import CommandGroupFilter
11
+ from astrbot.core.star.filter.permission import PermissionType, PermissionTypeFilter
12
+ from astrbot.core.star.star import star_map
13
+ from astrbot.core.star.star_handler import StarHandlerMetadata, star_handlers_registry
14
+
15
+
16
+ @dataclass
17
+ class CommandDescriptor:
18
+ handler: StarHandlerMetadata = field(repr=False)
19
+ filter_ref: CommandFilter | CommandGroupFilter | None = field(
20
+ default=None,
21
+ repr=False,
22
+ )
23
+ handler_full_name: str = ""
24
+ handler_name: str = ""
25
+ plugin_name: str = ""
26
+ plugin_display_name: str | None = None
27
+ module_path: str = ""
28
+ description: str = ""
29
+ command_type: str = "command" # "command" | "group" | "sub_command"
30
+ raw_command_name: str | None = None
31
+ current_fragment: str | None = None
32
+ parent_signature: str = ""
33
+ parent_group_handler: str = ""
34
+ original_command: str | None = None
35
+ effective_command: str | None = None
36
+ aliases: list[str] = field(default_factory=list)
37
+ permission: str = "everyone"
38
+ enabled: bool = True
39
+ is_group: bool = False
40
+ is_sub_command: bool = False
41
+ reserved: bool = False
42
+ config: CommandConfig | None = None
43
+ has_conflict: bool = False
44
+ sub_commands: list[CommandDescriptor] = field(default_factory=list)
45
+
46
+
47
+ async def sync_command_configs() -> None:
48
+ """同步指令配置,清理过期配置。"""
49
+ descriptors = _collect_descriptors(include_sub_commands=False)
50
+ config_records = await db_helper.get_command_configs()
51
+ config_map = _bind_configs_to_descriptors(descriptors, config_records)
52
+ live_handlers = {desc.handler_full_name for desc in descriptors}
53
+
54
+ stale_configs = [key for key in config_map if key not in live_handlers]
55
+ if stale_configs:
56
+ await db_helper.delete_command_configs(stale_configs)
57
+
58
+
59
+ async def toggle_command(handler_full_name: str, enabled: bool) -> CommandDescriptor:
60
+ descriptor = _build_descriptor_by_full_name(handler_full_name)
61
+ if not descriptor:
62
+ raise ValueError("指定的处理函数不存在或不是指令。")
63
+
64
+ existing_cfg = await db_helper.get_command_config(handler_full_name)
65
+ config = await db_helper.upsert_command_config(
66
+ handler_full_name=handler_full_name,
67
+ plugin_name=descriptor.plugin_name or "",
68
+ module_path=descriptor.module_path,
69
+ original_command=descriptor.original_command or descriptor.handler_name,
70
+ resolved_command=(
71
+ existing_cfg.resolved_command
72
+ if existing_cfg
73
+ else descriptor.current_fragment
74
+ ),
75
+ enabled=enabled,
76
+ keep_original_alias=False,
77
+ conflict_key=existing_cfg.conflict_key
78
+ if existing_cfg and existing_cfg.conflict_key
79
+ else descriptor.original_command,
80
+ resolution_strategy=existing_cfg.resolution_strategy if existing_cfg else None,
81
+ note=existing_cfg.note if existing_cfg else None,
82
+ extra_data=existing_cfg.extra_data if existing_cfg else None,
83
+ auto_managed=False,
84
+ )
85
+ _bind_descriptor_with_config(descriptor, config)
86
+ await sync_command_configs()
87
+ return descriptor
88
+
89
+
90
+ async def rename_command(
91
+ handler_full_name: str,
92
+ new_fragment: str,
93
+ ) -> CommandDescriptor:
94
+ descriptor = _build_descriptor_by_full_name(handler_full_name)
95
+ if not descriptor:
96
+ raise ValueError("指定的处理函数不存在或不是指令。")
97
+
98
+ new_fragment = new_fragment.strip()
99
+ if not new_fragment:
100
+ raise ValueError("指令名不能为空。")
101
+
102
+ candidate_full = _compose_command(descriptor.parent_signature, new_fragment)
103
+ if _is_command_in_use(handler_full_name, candidate_full):
104
+ raise ValueError("新的指令名已被其他指令占用,请换一个名称。")
105
+
106
+ config = await db_helper.upsert_command_config(
107
+ handler_full_name=handler_full_name,
108
+ plugin_name=descriptor.plugin_name or "",
109
+ module_path=descriptor.module_path,
110
+ original_command=descriptor.original_command or descriptor.handler_name,
111
+ resolved_command=new_fragment,
112
+ enabled=True if descriptor.enabled else False,
113
+ keep_original_alias=False,
114
+ conflict_key=descriptor.original_command,
115
+ resolution_strategy="manual_rename",
116
+ note=None,
117
+ extra_data=None,
118
+ auto_managed=False,
119
+ )
120
+ _bind_descriptor_with_config(descriptor, config)
121
+
122
+ await sync_command_configs()
123
+ return descriptor
124
+
125
+
126
+ async def list_commands() -> list[dict[str, Any]]:
127
+ descriptors = _collect_descriptors(include_sub_commands=True)
128
+ config_records = await db_helper.get_command_configs()
129
+ _bind_configs_to_descriptors(descriptors, config_records)
130
+
131
+ conflict_groups = _group_conflicts(descriptors)
132
+ conflict_handler_names: set[str] = {
133
+ d.handler_full_name for group in conflict_groups.values() for d in group
134
+ }
135
+
136
+ # 分类,设置冲突标志,将子指令挂载到父指令组
137
+ group_map: dict[str, CommandDescriptor] = {}
138
+ sub_commands: list[CommandDescriptor] = []
139
+ root_commands: list[CommandDescriptor] = []
140
+
141
+ for desc in descriptors:
142
+ desc.has_conflict = desc.handler_full_name in conflict_handler_names
143
+ if desc.is_group:
144
+ group_map[desc.handler_full_name] = desc
145
+ elif desc.is_sub_command:
146
+ sub_commands.append(desc)
147
+ else:
148
+ root_commands.append(desc)
149
+
150
+ for sub in sub_commands:
151
+ if sub.parent_group_handler and sub.parent_group_handler in group_map:
152
+ group_map[sub.parent_group_handler].sub_commands.append(sub)
153
+ else:
154
+ root_commands.append(sub)
155
+
156
+ # 指令组 + 普通指令,按 effective_command 字母排序
157
+ all_commands = list(group_map.values()) + root_commands
158
+ all_commands.sort(key=lambda d: (d.effective_command or "").lower())
159
+
160
+ result = [_descriptor_to_dict(desc) for desc in all_commands]
161
+ return result
162
+
163
+
164
+ async def list_command_conflicts() -> list[dict[str, Any]]:
165
+ """列出所有冲突的指令组。"""
166
+ descriptors = _collect_descriptors(include_sub_commands=False)
167
+ config_records = await db_helper.get_command_configs()
168
+ _bind_configs_to_descriptors(descriptors, config_records)
169
+
170
+ conflict_groups = _group_conflicts(descriptors)
171
+ details = [
172
+ {
173
+ "conflict_key": key,
174
+ "handlers": [
175
+ {
176
+ "handler_full_name": item.handler_full_name,
177
+ "plugin": item.plugin_name,
178
+ "current_name": item.effective_command,
179
+ }
180
+ for item in group
181
+ ],
182
+ }
183
+ for key, group in conflict_groups.items()
184
+ ]
185
+ return details
186
+
187
+
188
+ # Internal helpers ----------------------------------------------------------
189
+
190
+
191
+ def _collect_descriptors(include_sub_commands: bool) -> list[CommandDescriptor]:
192
+ """收集指令,按需包含子指令。"""
193
+ descriptors: list[CommandDescriptor] = []
194
+ for handler in star_handlers_registry:
195
+ desc = _build_descriptor(handler)
196
+ if not desc:
197
+ continue
198
+ if not include_sub_commands and desc.is_sub_command:
199
+ continue
200
+ descriptors.append(desc)
201
+ return descriptors
202
+
203
+
204
+ def _build_descriptor(handler: StarHandlerMetadata) -> CommandDescriptor | None:
205
+ filter_ref = _locate_primary_filter(handler)
206
+ if filter_ref is None:
207
+ return None
208
+
209
+ plugin_meta = star_map.get(handler.handler_module_path)
210
+ plugin_name = (
211
+ plugin_meta.name if plugin_meta else None
212
+ ) or handler.handler_module_path
213
+ plugin_display = plugin_meta.display_name if plugin_meta else None
214
+
215
+ is_sub_command = bool(handler.extras_configs.get("sub_command"))
216
+ parent_group_handler = ""
217
+
218
+ if isinstance(filter_ref, CommandFilter):
219
+ raw_fragment = getattr(
220
+ filter_ref, "_original_command_name", filter_ref.command_name
221
+ )
222
+ current_fragment = filter_ref.command_name
223
+ parent_signature = (filter_ref.parent_command_names or [""])[0].strip()
224
+ # 如果是子指令,尝试找到父指令组的 handler_full_name
225
+ if is_sub_command and parent_signature:
226
+ parent_group_handler = _find_parent_group_handler(
227
+ handler.handler_module_path, parent_signature
228
+ )
229
+ else:
230
+ raw_fragment = getattr(
231
+ filter_ref, "_original_group_name", filter_ref.group_name
232
+ )
233
+ current_fragment = filter_ref.group_name
234
+ parent_signature = _resolve_group_parent_signature(filter_ref)
235
+
236
+ original_command = _compose_command(parent_signature, raw_fragment)
237
+ effective_command = _compose_command(parent_signature, current_fragment)
238
+
239
+ # 确定 command_type
240
+ if isinstance(filter_ref, CommandGroupFilter):
241
+ command_type = "group"
242
+ elif is_sub_command:
243
+ command_type = "sub_command"
244
+ else:
245
+ command_type = "command"
246
+
247
+ descriptor = CommandDescriptor(
248
+ handler=handler,
249
+ filter_ref=filter_ref,
250
+ handler_full_name=handler.handler_full_name,
251
+ handler_name=handler.handler_name,
252
+ plugin_name=plugin_name,
253
+ plugin_display_name=plugin_display,
254
+ module_path=handler.handler_module_path,
255
+ description=handler.desc or "",
256
+ command_type=command_type,
257
+ raw_command_name=raw_fragment,
258
+ current_fragment=current_fragment,
259
+ parent_signature=parent_signature,
260
+ parent_group_handler=parent_group_handler,
261
+ original_command=original_command,
262
+ effective_command=effective_command,
263
+ aliases=sorted(getattr(filter_ref, "alias", set())),
264
+ permission=_determine_permission(handler),
265
+ enabled=handler.enabled,
266
+ is_group=isinstance(filter_ref, CommandGroupFilter),
267
+ is_sub_command=is_sub_command,
268
+ reserved=plugin_meta.reserved if plugin_meta else False,
269
+ )
270
+ return descriptor
271
+
272
+
273
+ def _build_descriptor_by_full_name(full_name: str) -> CommandDescriptor | None:
274
+ handler = star_handlers_registry.get_handler_by_full_name(full_name)
275
+ if not handler:
276
+ return None
277
+ return _build_descriptor(handler)
278
+
279
+
280
+ def _locate_primary_filter(
281
+ handler: StarHandlerMetadata,
282
+ ) -> CommandFilter | CommandGroupFilter | None:
283
+ for filter_ref in handler.event_filters:
284
+ if isinstance(filter_ref, (CommandFilter, CommandGroupFilter)):
285
+ return filter_ref
286
+ return None
287
+
288
+
289
+ def _determine_permission(handler: StarHandlerMetadata) -> str:
290
+ for filter_ref in handler.event_filters:
291
+ if isinstance(filter_ref, PermissionTypeFilter):
292
+ return (
293
+ "admin"
294
+ if filter_ref.permission_type == PermissionType.ADMIN
295
+ else "member"
296
+ )
297
+ return "everyone"
298
+
299
+
300
+ def _resolve_group_parent_signature(group_filter: CommandGroupFilter) -> str:
301
+ signatures: list[str] = []
302
+ parent = group_filter.parent_group
303
+ while parent:
304
+ signatures.append(getattr(parent, "_original_group_name", parent.group_name))
305
+ parent = parent.parent_group
306
+ return " ".join(reversed(signatures)).strip()
307
+
308
+
309
+ def _find_parent_group_handler(module_path: str, parent_signature: str) -> str:
310
+ """根据模块路径和父级签名,找到对应的指令组 handler_full_name。"""
311
+ parent_sig_normalized = parent_signature.strip()
312
+ for handler in star_handlers_registry:
313
+ if handler.handler_module_path != module_path:
314
+ continue
315
+ filter_ref = _locate_primary_filter(handler)
316
+ if not isinstance(filter_ref, CommandGroupFilter):
317
+ continue
318
+ # 检查该指令组的完整指令名是否匹配 parent_signature
319
+ group_names = filter_ref.get_complete_command_names()
320
+ if parent_sig_normalized in group_names:
321
+ return handler.handler_full_name
322
+ return ""
323
+
324
+
325
+ def _compose_command(parent_signature: str, fragment: str | None) -> str:
326
+ fragment = (fragment or "").strip()
327
+ parent_signature = parent_signature.strip()
328
+ if not parent_signature:
329
+ return fragment
330
+ if not fragment:
331
+ return parent_signature
332
+ return f"{parent_signature} {fragment}"
333
+
334
+
335
+ def _bind_descriptor_with_config(
336
+ descriptor: CommandDescriptor,
337
+ config: CommandConfig,
338
+ ) -> None:
339
+ _apply_config_to_descriptor(descriptor, config)
340
+ _apply_config_to_runtime(descriptor, config)
341
+
342
+
343
+ def _apply_config_to_descriptor(
344
+ descriptor: CommandDescriptor,
345
+ config: CommandConfig,
346
+ ) -> None:
347
+ descriptor.config = config
348
+ descriptor.enabled = config.enabled
349
+
350
+ if config.original_command:
351
+ descriptor.original_command = config.original_command
352
+
353
+ new_fragment = config.resolved_command or descriptor.current_fragment
354
+ descriptor.current_fragment = new_fragment
355
+ descriptor.effective_command = _compose_command(
356
+ descriptor.parent_signature,
357
+ new_fragment,
358
+ )
359
+
360
+
361
+ def _apply_config_to_runtime(
362
+ descriptor: CommandDescriptor,
363
+ config: CommandConfig,
364
+ ) -> None:
365
+ descriptor.handler.enabled = config.enabled
366
+ if descriptor.filter_ref and descriptor.current_fragment:
367
+ _set_filter_fragment(descriptor.filter_ref, descriptor.current_fragment)
368
+
369
+
370
+ def _bind_configs_to_descriptors(
371
+ descriptors: list[CommandDescriptor],
372
+ config_records: list[CommandConfig],
373
+ ) -> dict[str, CommandConfig]:
374
+ config_map = {cfg.handler_full_name: cfg for cfg in config_records}
375
+ for desc in descriptors:
376
+ if cfg := config_map.get(desc.handler_full_name):
377
+ _bind_descriptor_with_config(desc, cfg)
378
+ return config_map
379
+
380
+
381
+ def _group_conflicts(
382
+ descriptors: list[CommandDescriptor],
383
+ ) -> dict[str, list[CommandDescriptor]]:
384
+ conflicts: dict[str, list[CommandDescriptor]] = defaultdict(list)
385
+ for desc in descriptors:
386
+ if desc.effective_command and desc.enabled:
387
+ conflicts[desc.effective_command].append(desc)
388
+ return {k: v for k, v in conflicts.items() if len(v) > 1}
389
+
390
+
391
+ def _set_filter_fragment(
392
+ filter_ref: CommandFilter | CommandGroupFilter,
393
+ fragment: str,
394
+ ) -> None:
395
+ attr = (
396
+ "group_name" if isinstance(filter_ref, CommandGroupFilter) else "command_name"
397
+ )
398
+ current_value = getattr(filter_ref, attr)
399
+ if fragment == current_value:
400
+ return
401
+ setattr(filter_ref, attr, fragment)
402
+ if hasattr(filter_ref, "_cmpl_cmd_names"):
403
+ filter_ref._cmpl_cmd_names = None
404
+
405
+
406
+ def _is_command_in_use(
407
+ target_handler_full_name: str,
408
+ candidate_full_command: str,
409
+ ) -> bool:
410
+ candidate = candidate_full_command.strip()
411
+ for handler in star_handlers_registry:
412
+ if handler.handler_full_name == target_handler_full_name:
413
+ continue
414
+ filter_ref = _locate_primary_filter(handler)
415
+ if not filter_ref:
416
+ continue
417
+ names = {name.strip() for name in filter_ref.get_complete_command_names()}
418
+ if candidate in names:
419
+ return True
420
+ return False
421
+
422
+
423
+ def _descriptor_to_dict(desc: CommandDescriptor) -> dict[str, Any]:
424
+ result = {
425
+ "handler_full_name": desc.handler_full_name,
426
+ "handler_name": desc.handler_name,
427
+ "plugin": desc.plugin_name,
428
+ "plugin_display_name": desc.plugin_display_name,
429
+ "module_path": desc.module_path,
430
+ "description": desc.description,
431
+ "type": desc.command_type,
432
+ "parent_signature": desc.parent_signature,
433
+ "parent_group_handler": desc.parent_group_handler,
434
+ "original_command": desc.original_command,
435
+ "current_fragment": desc.current_fragment,
436
+ "effective_command": desc.effective_command,
437
+ "aliases": desc.aliases,
438
+ "permission": desc.permission,
439
+ "enabled": desc.enabled,
440
+ "is_group": desc.is_group,
441
+ "has_conflict": desc.has_conflict,
442
+ "reserved": desc.reserved,
443
+ }
444
+ # 如果是指令组,包含子指令列表
445
+ if desc.is_group and desc.sub_commands:
446
+ result["sub_commands"] = [_descriptor_to_dict(sub) for sub in desc.sub_commands]
447
+ else:
448
+ result["sub_commands"] = []
449
+ return result
@@ -267,6 +267,10 @@ class Context:
267
267
  ):
268
268
  """通过 ID 获取对应的 LLM Provider。"""
269
269
  prov = self.provider_manager.inst_map.get(provider_id)
270
+ if provider_id and not prov:
271
+ logger.warning(
272
+ f"没有找到 ID 为 {provider_id} 的提供商,这可能是由于您修改了提供商(模型)ID 导致的。"
273
+ )
270
274
  return prov
271
275
 
272
276
  def get_all_providers(self) -> list[Provider]:
@@ -40,6 +40,7 @@ class CommandFilter(HandlerFilter):
40
40
  ):
41
41
  self.command_name = command_name
42
42
  self.alias = alias if alias else set()
43
+ self._original_command_name = command_name
43
44
  self.parent_command_names = (
44
45
  parent_command_names if parent_command_names is not None else [""]
45
46
  )
@@ -18,6 +18,7 @@ class CommandGroupFilter(HandlerFilter):
18
18
  ):
19
19
  self.group_name = group_name
20
20
  self.alias = alias if alias else set()
21
+ self._original_group_name = group_name
21
22
  self.sub_command_filters: list[CommandFilter | CommandGroupFilter] = []
22
23
  self.custom_filter_list: list[CustomFilter] = []
23
24
  self.parent_group = parent_group
@@ -118,6 +118,8 @@ class StarHandlerRegistry(Generic[T]):
118
118
  # 过滤事件类型
119
119
  if handler.event_type != event_type:
120
120
  continue
121
+ if not handler.enabled:
122
+ continue
121
123
  # 过滤启用状态
122
124
  if only_activated:
123
125
  plugin = star_map.get(handler.handler_module_path)
@@ -220,6 +222,8 @@ class StarHandlerMetadata(Generic[H]):
220
222
  extras_configs: dict = field(default_factory=dict)
221
223
  """插件注册的一些其他的信息, 如 priority 等"""
222
224
 
225
+ enabled: bool = True
226
+
223
227
  def __lt__(self, other: StarHandlerMetadata):
224
228
  """定义小于运算符以支持优先队列"""
225
229
  return self.extras_configs.get("priority", 0) < other.extras_configs.get(
@@ -23,6 +23,7 @@ from astrbot.core.utils.astrbot_path import (
23
23
  from astrbot.core.utils.io import remove_dir
24
24
 
25
25
  from . import StarMetadata
26
+ from .command_management import sync_command_configs
26
27
  from .context import Context
27
28
  from .filter.permission import PermissionType, PermissionTypeFilter
28
29
  from .star import star_map, star_registry
@@ -630,6 +631,7 @@ class PluginManager:
630
631
  # 清除 pip.main 导致的多余的 logging handlers
631
632
  for handler in logging.root.handlers[:]:
632
633
  logging.root.removeHandler(handler)
634
+ await sync_command_configs()
633
635
 
634
636
  if not fail_rec:
635
637
  return True, None
@@ -0,0 +1,63 @@
1
+ from typing import Literal, TypedDict
2
+
3
+ import aiohttp
4
+
5
+ from astrbot.core import logger
6
+
7
+
8
+ class LLMModalities(TypedDict):
9
+ input: list[Literal["text", "image", "audio", "video"]]
10
+ output: list[Literal["text", "image", "audio", "video"]]
11
+
12
+
13
+ class LLMLimit(TypedDict):
14
+ context: int
15
+ output: int
16
+
17
+
18
+ class LLMMetadata(TypedDict):
19
+ id: str
20
+ reasoning: bool
21
+ tool_call: bool
22
+ knowledge: str
23
+ release_date: str
24
+ modalities: LLMModalities
25
+ open_weights: bool
26
+ limit: LLMLimit
27
+
28
+
29
+ LLM_METADATAS: dict[str, LLMMetadata] = {}
30
+
31
+
32
+ async def update_llm_metadata():
33
+ url = "https://models.dev/api.json"
34
+ try:
35
+ async with aiohttp.ClientSession() as session:
36
+ async with session.get(url) as response:
37
+ data = await response.json()
38
+ global LLM_METADATAS
39
+ models = {}
40
+ for info in data.values():
41
+ for model in info.get("models", {}).values():
42
+ model_id = model.get("id")
43
+ if not model_id:
44
+ continue
45
+ models[model_id] = LLMMetadata(
46
+ id=model_id,
47
+ reasoning=model.get("reasoning", False),
48
+ tool_call=model.get("tool_call", False),
49
+ knowledge=model.get("knowledge", "none"),
50
+ release_date=model.get("release_date", ""),
51
+ modalities=model.get(
52
+ "modalities", {"input": [], "output": []}
53
+ ),
54
+ open_weights=model.get("open_weights", False),
55
+ limit=model.get("limit", {"context": 0, "output": 0}),
56
+ )
57
+ # Replace the global cache in-place so references remain valid
58
+ LLM_METADATAS.clear()
59
+ LLM_METADATAS.update(models)
60
+ logger.info(f"Successfully fetched metadata for {len(models)} LLMs.")
61
+ except Exception as e:
62
+ logger.error(f"Failed to fetch LLM metadata: {e}")
63
+ return
@@ -32,6 +32,92 @@ def _migra_agent_runner_configs(conf: AstrBotConfig, ids_map: dict) -> None:
32
32
  logger.error(traceback.format_exc())
33
33
 
34
34
 
35
+ def _migra_provider_to_source_structure(conf: AstrBotConfig) -> None:
36
+ """
37
+ Migrate old provider structure to new provider-source separation.
38
+ Provider only keeps: id, provider_source_id, model, modalities, custom_extra_body
39
+ All other fields move to provider_sources.
40
+ """
41
+ providers = conf.get("provider", [])
42
+ provider_sources = conf.get("provider_sources", [])
43
+
44
+ # Track if any migration happened
45
+ migrated = False
46
+
47
+ # Provider-only fields that should stay in provider
48
+ provider_only_fields = {
49
+ "id",
50
+ "provider_source_id",
51
+ "model",
52
+ "modalities",
53
+ "custom_extra_body",
54
+ "enable",
55
+ }
56
+
57
+ # Fields that should not go to source
58
+ source_exclude_fields = provider_only_fields | {"model_config"}
59
+
60
+ for provider in providers:
61
+ # Skip if already has provider_source_id
62
+ if provider.get("provider_source_id"):
63
+ continue
64
+
65
+ # Skip non-chat-completion types (they don't need source separation)
66
+ provider_type = provider.get("provider_type", "")
67
+ if provider_type != "chat_completion":
68
+ # For old types without provider_type, check type field
69
+ old_type = provider.get("type", "")
70
+ if "chat_completion" not in old_type:
71
+ continue
72
+
73
+ migrated = True
74
+ logger.info(f"Migrating provider {provider.get('id')} to new structure")
75
+
76
+ # Extract source fields from provider
77
+ source_fields = {}
78
+ for key, value in list(provider.items()):
79
+ if key not in source_exclude_fields:
80
+ source_fields[key] = value
81
+
82
+ # Create new provider_source
83
+ source_id = provider.get("id", "") + "_source"
84
+ new_source = {"id": source_id, **source_fields}
85
+
86
+ # Update provider to only keep necessary fields
87
+ provider["provider_source_id"] = source_id
88
+
89
+ # Extract model from model_config if exists
90
+ if "model_config" in provider and isinstance(provider["model_config"], dict):
91
+ model_config = provider["model_config"]
92
+ provider["model"] = model_config.get("model", "")
93
+
94
+ # Put other model_config fields into custom_extra_body
95
+ extra_body_fields = {k: v for k, v in model_config.items() if k != "model"}
96
+ if extra_body_fields:
97
+ if "custom_extra_body" not in provider:
98
+ provider["custom_extra_body"] = {}
99
+ provider["custom_extra_body"].update(extra_body_fields)
100
+
101
+ # Initialize new fields if not present
102
+ if "modalities" not in provider:
103
+ provider["modalities"] = []
104
+ if "custom_extra_body" not in provider:
105
+ provider["custom_extra_body"] = {}
106
+
107
+ # Remove fields that should be in source
108
+ keys_to_remove = [k for k in provider.keys() if k not in provider_only_fields]
109
+ for key in keys_to_remove:
110
+ del provider[key]
111
+
112
+ # Add source to provider_sources
113
+ provider_sources.append(new_source)
114
+
115
+ if migrated:
116
+ conf["provider_sources"] = provider_sources
117
+ conf.save_config()
118
+ logger.info("Provider-source structure migration completed")
119
+
120
+
35
121
  async def migra(
36
122
  db, astrbot_config_mgr, umop_config_router, acm: AstrBotConfigManager
37
123
  ) -> None:
@@ -71,3 +157,10 @@ async def migra(
71
157
 
72
158
  for conf in acm.confs.values():
73
159
  _migra_agent_runner_configs(conf, ids_map)
160
+
161
+ # Migrate providers to new structure: extract source fields to provider_sources
162
+ try:
163
+ _migra_provider_to_source_structure(astrbot_config)
164
+ except Exception as e:
165
+ logger.error(f"Migration for provider-source structure failed: {e!s}")
166
+ logger.error(traceback.format_exc())
@@ -1,5 +1,6 @@
1
1
  from .auth import AuthRoute
2
2
  from .chat import ChatRoute
3
+ from .command import CommandRoute
3
4
  from .config import ConfigRoute
4
5
  from .conversation import ConversationRoute
5
6
  from .file import FileRoute
@@ -17,6 +18,7 @@ from .update import UpdateRoute
17
18
  __all__ = [
18
19
  "AuthRoute",
19
20
  "ChatRoute",
21
+ "CommandRoute",
20
22
  "ConfigRoute",
21
23
  "ConversationRoute",
22
24
  "FileRoute",