Files
skill-template/llm-manager/scripts/main.py
2026-04-04 10:35:02 +08:00

576 lines
22 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
llm-manager 主入口 CLI。
子命令:
health 快速离线健康检查
version 输出版本 JSON
add <platform> [api_key] 添加 API Key不传 api_key 则走网页账号关联)
key list [platform] 列出 Key打码
key del <key_id> 删除 Key
generate <platform_or_account_id> 生成内容(优先网页模式,备用 API Key 模式)
"<prompt>"
generate 调度规则:
1. 若 target 为纯数字 → 视为 account-manager 账号 ID → 强制网页模式
2. 若 target 为平台名/别名:
a. 先查 account-manager 有无该平台已登录账号 → 网页模式(免费)
b. 再查 llm_keys 有无可用 Key → API Key 模式(付费)
c. 两者均无 → 报错并给出操作指引
"""
import sys
import json
import os
import asyncio
import subprocess
# Windows GBK 编码兼容修复
if sys.platform == "win32":
import io
sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding="utf-8", errors="replace")
sys.stderr = io.TextIOWrapper(sys.stderr.buffer, encoding="utf-8", errors="replace")
BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
OPENCLAW_DIR = os.path.dirname(BASE_DIR)
# 确保 scripts 目录在 sys.path使 providers/db 可直接 import
_scripts_dir = os.path.dirname(os.path.abspath(__file__))
if _scripts_dir not in sys.path:
sys.path.insert(0, _scripts_dir)
from providers import (
LLM_PROVIDERS,
resolve_provider_key,
provider_list_cn,
find_logged_in_account,
resolve_chromium_channel,
)
from db import (
add_key,
upsert_web_account,
list_keys,
list_web_accounts,
get_key_by_id,
delete_key,
find_active_key,
mark_key_used,
_mask_key,
)
from engines.api_engine import ApiEngine
from engines.kimi import KimiEngine
from engines.doubao import DoubaoEngine
from engines.deepseek import DeepSeekEngine
from engines.qianwen import QianwenEngine
from engines.yiyan import YiyanEngine
from engines.yuanbao import YuanbaoEngine
# 平台 slug → 网页引擎类(全部 6 个平台)
WEB_ENGINES = {
"doubao": DoubaoEngine,
"deepseek": DeepSeekEngine,
"qianwen": QianwenEngine,
"kimi": KimiEngine,
"yiyan": YiyanEngine,
"yuanbao": YuanbaoEngine,
}
SKILL_VERSION = "1.0.3"
def _engine_result_is_error(text: str) -> bool:
"""网页/API 引擎约定:失败时返回以 ERROR: 开头的字符串,不得当作正文包进 LLM 标记块。"""
return (text or "").lstrip().startswith("ERROR:")
def _unix_to_iso(ts):
if ts is None:
return ""
try:
import datetime as _dt
return _dt.datetime.fromtimestamp(int(ts)).isoformat(timespec="seconds")
except Exception:
return str(ts)
# ---------------------------------------------------------------------------
# account-manager 跨 Skill 调用
# ---------------------------------------------------------------------------
def _get_account_from_manager(account_id) -> dict | None:
"""通过 account-manager CLI 按 ID 取账号 JSON。"""
script = os.path.join(OPENCLAW_DIR, "account-manager", "scripts", "main.py")
try:
result = subprocess.run(
[sys.executable, script, "get", str(account_id)],
capture_output=True, text=True, encoding="utf-8", errors="replace",
)
raw = result.stdout.strip()
if raw.startswith("ERROR"):
return None
return json.loads(raw)
except Exception:
return None
# ---------------------------------------------------------------------------
# 网页模式
# ---------------------------------------------------------------------------
async def _run_web_generate(account: dict, prompt: str) -> bool:
from playwright.async_api import async_playwright
platform = account.get("platform", "")
profile_dir = account.get("profile_dir", "").strip()
engine_cls = WEB_ENGINES.get(platform)
if not engine_cls:
print(f"ERROR:ENGINE_NOT_FOUND 平台 {platform} 暂无网页引擎实现。")
return False
if not profile_dir or not os.path.isdir(profile_dir):
print(f"ERROR:PROFILE_DIR_MISSING 账号 {account.get('id')} 的浏览器数据目录不存在:{profile_dir}")
print("请先通过 account-manager 执行登录python account-manager/scripts/main.py login <id>")
return False
channel = resolve_chromium_channel()
if not channel:
print("ERROR:浏览器未找到。请安装 Google Chrome 或 Microsoft Edge 后重试。")
return False
print(f"[llm-manager] 使用网页模式 | 平台: {LLM_PROVIDERS.get(platform, {}).get('label', platform)} | 账号: {account.get('name', account.get('id'))}")
async with async_playwright() as p:
browser = await p.chromium.launch_persistent_context(
user_data_dir=profile_dir,
headless=False,
channel=channel,
no_viewport=True,
permissions=["clipboard-read", "clipboard-write"],
args=["--start-maximized"],
)
try:
page = browser.pages[0] if browser.pages else await browser.new_page()
engine = engine_cls(page)
try:
result = await engine.generate(prompt)
except Exception as e:
print(f"ERROR:WEB_GENERATE_FAILED 网页模式生成失败:{e}")
return False
result = (result or "").strip()
if _engine_result_is_error(result):
print(result)
return False
print("===LLM_START===")
print(result)
print("===LLM_END===")
return True
finally:
await asyncio.sleep(3)
await browser.close()
# ---------------------------------------------------------------------------
# API Key 模式
# ---------------------------------------------------------------------------
def _run_api_generate(provider_key: str, key_record: dict, prompt: str) -> bool:
provider = LLM_PROVIDERS[provider_key]
api_base = provider.get("api_base")
model = (key_record.get("default_model") or "").strip() or (provider.get("api_model") or "").strip()
if not api_base:
print(f"ERROR:NO_API_BASE {provider['label']} 暂无 API 地址(不支持 API 模式)。")
return False
if not model:
print(
f"ERROR:API_MODEL_MISSING {provider['label']} 需要指定模型名称。\n"
f"提示:{provider.get('api_note', '')}\n"
f"请删除该 Key 并重新添加时带上 --model 参数python main.py add {provider_key} \"Key\" --model \"模型名\""
)
return False
print(f"[llm-manager] 使用 API Key 模式 | 平台: {provider['label']} | 模型: {model}")
try:
engine = ApiEngine(api_base=api_base, api_key=key_record["api_key"], model=model)
result = engine.generate(prompt)
except Exception as e:
print(f"ERROR:API_GENERATE_FAILED API 模式调用失败:{e}")
return False
result = (result or "").strip()
if _engine_result_is_error(result):
print(result)
return False
mark_key_used(key_record["id"])
print("===LLM_START===")
print(result)
print("===LLM_END===")
return True
# ---------------------------------------------------------------------------
# generate 命令
# ---------------------------------------------------------------------------
def cmd_generate(target: str, prompt: str) -> bool:
"""
target 可以是:
- 纯数字 → account-manager 账号 ID强制网页模式
- 平台名/别名 → 自动选择模式网页优先API Key 备用)
成功返回 True任一步失败返回 False调用方应设进程退出码非 0
"""
target = (target or "").strip()
prompt = (prompt or "").strip()
if not prompt:
print("ERROR:PROMPT_EMPTY 提示词不能为空。")
return False
# ---- 情况 1显式 account_id纯数字→ 强制网页模式 ----
if target.isdigit():
account = _get_account_from_manager(target)
if not account:
print(f"ERROR:ACCOUNT_NOT_FOUND 未在「模型管理」对应的账号列表中找到 id={target}")
print("请先在模型管理中添加该平台账号,或检查 id 是否抄错。")
return False
if account.get("login_status") != 1:
print(
f"ERROR:REQUIRE_LOGIN 账号「{account.get('name', target)}」尚未完成网页登录。\n"
"请先在「模型管理」里确认已添加该账号,再执行下面命令完成登录:\n"
f" python account-manager/scripts/main.py login {target}\n"
"若使用网页模式:请先在对应平台官网注册好账号,再回到本工具添加并登录。"
)
return False
return asyncio.run(_run_web_generate(account, prompt))
# ---- 情况 2平台名 → 自动选模式 ----
provider_key = resolve_provider_key(target)
if not provider_key:
print(f"ERROR:INVALID_PLATFORM 无法识别的平台「{target}」。")
print("支持的平台:" + provider_list_cn())
return False
provider = LLM_PROVIDERS[provider_key]
label = provider["label"]
web_url = (provider.get("web_url") or "").strip()
# 优先级 1查 account-manager 已登录账号(网页模式,免费)
account = find_logged_in_account(provider_key)
if account:
return asyncio.run(_run_web_generate(account, prompt))
# 优先级 2查本地 API Key付费
key_record = find_active_key(provider_key)
if key_record:
return _run_api_generate(provider_key, key_record, prompt)
# 两种凭据均无 → 明确说明「模型管理 + 网页模式需官网账号」
print(f"ERROR:NO_CREDENTIAL 当前没有可用的「{label}」调用凭据(既没有已登录的网页账号,也没有可用的 API Key")
print()
print("【推荐 · 网页模式(免费)】按顺序做:")
print(f" ① 打开「模型管理」(与 OpenClaw 里 account-manager 账号管理是同一套数据),先把「{label}」账号添加进去。")
print(" 命令行示例(在 OpenClaw 根目录执行):")
print(f" python account-manager/scripts/main.py add \"{label}\" \"你的手机号或登录名\"")
print(" python account-manager/scripts/main.py login <上一步返回的账号 id>")
print(" ② 若走网页模式:请先在对应平台官方网站注册并创建好账号(否则浏览器里登录会失败)。")
if web_url:
print(f"{label}」网页入口:{web_url}")
print()
if provider.get("has_api"):
print("【备选 · API Key付费】若已在厂商控制台开通 API可本地登记 Key 后走接口调用:")
print(f" python llm-manager/scripts/main.py add {provider_key} \"你的API Key\"")
if provider.get("api_note"):
print(f" 说明:{provider['api_note']}")
else:
print(f"说明:「{label}」暂无公开 API只能使用上面的网页模式。")
return False
# ---------------------------------------------------------------------------
# key 子命令
# ---------------------------------------------------------------------------
def cmd_key_add(provider_input: str, api_key: str = "", model: str = None, label: str = ""):
provider_key = resolve_provider_key(provider_input)
if not provider_key:
print(f"ERROR:INVALID_PLATFORM 无法识别的平台「{provider_input}」。")
print("支持:" + provider_list_cn())
return
provider = LLM_PROVIDERS[provider_key]
api_key = (api_key or "").strip()
# 不传 api_key默认走网页版本检查 account-manager 是否已有该平台已登录账号
if not api_key:
account = find_logged_in_account(provider_key)
if account:
web_row_id = upsert_web_account(
provider=provider_key,
account_id=int(account.get("id")),
account_name=(account.get("name") or account.get("account_name") or ""),
login_status=int(account.get("login_status") or 0),
)
print(
f"OK:WEB_ACCOUNT_READY 已关联网页模式账号 | 平台: {provider['label']} "
f"| account_id: {account.get('id')} | 账号: {account.get('name') or account.get('account_name') or ''}"
)
print(f"已写入 llm-manager 记录WEB_ID {web_row_id}")
print(f"后续可直接调用python main.py generate {provider_key} \"<提示词>\"")
return
print(f"ERROR:WEB_ACCOUNT_NOT_FOUND 未找到已登录的「{provider['label']}」账号,暂时无法走网页模式。")
print("请先在 account-manager 添加并登录该平台账号:")
print(f" python account-manager/scripts/main.py add \"{provider['label']}\" \"你的登录名\"")
print(" python account-manager/scripts/main.py login <账号id>")
if provider.get("has_api"):
print("或直接提供 API Key")
print(f" python main.py add {provider_key} \"<API_Key>\"")
return
if not provider.get("has_api"):
print(f"ERROR:{provider['label']} 暂无公开 API不支持 API Key 模式。")
print("请改用网页模式并先在 account-manager 完成登录。")
return
new_id = add_key(provider=provider_key, api_key=api_key, model=model, label=label)
print(f"✅ 已保存 API KeyID {new_id} | {provider['label']} | 模型: {model or provider.get('api_model') or '(未指定)'} | {_mask_key(api_key)}")
if not model and not provider.get("api_model"):
print(f"⚠️ 注意:该平台需要指定模型名称,否则调用时会报错。")
print(f" {provider.get('api_note', '')}")
print(f" 可删除后重新添加python main.py key del {new_id}")
def cmd_key_list(provider_input: str = None, limit: int = 10):
provider_key = None
if provider_input:
provider_key = resolve_provider_key(provider_input)
if not provider_key:
print(f"ERROR:INVALID_PLATFORM 无法识别的平台「{provider_input}」。")
return
keys = list_keys(provider_key, limit=limit)
web_accounts = list_web_accounts(provider_key, limit=limit)
if not keys and not web_accounts:
print("暂无记录API Key / 网页账号关联 都为空)。")
print("添加 API Keypython main.py add <platform> \"API Key\" [--model 模型名]")
print("添加网页账号关联python main.py add <platform>")
return
sep_line = "_" * 39
rows = []
for k in keys:
rows.append({
"type": "api_key",
"created_at": int(k.get("created_at") or 0),
"payload": k,
})
for w in web_accounts:
rows.append({
"type": "web_account",
"created_at": int(w.get("created_at") or 0),
"payload": w,
})
rows.sort(key=lambda x: (x["created_at"], int(x["payload"].get("id") or 0)), reverse=True)
for idx, row in enumerate(rows):
if row["type"] == "api_key":
k = row["payload"]
print("record_typeapi_key")
print(f"id{k['id']}")
print(f"platform{k['provider']}")
print(f"platform_cn{LLM_PROVIDERS.get(k['provider'], {}).get('label', k['provider'])}")
print(f"label{k.get('label') or ''}")
print(f"api_key{_mask_key(k.get('api_key') or '')}")
print(f"default_model{k.get('default_model') or ''}")
print(f"is_active{int(k.get('is_active') or 0)}")
print(f"last_used_at{_unix_to_iso(k.get('last_used_at'))}")
print(f"created_at{_unix_to_iso(k.get('created_at'))}")
else:
w = row["payload"]
print("record_typeweb_account")
print(f"id{w['id']}")
print(f"platform{w['provider']}")
print(f"platform_cn{LLM_PROVIDERS.get(w['provider'], {}).get('label', w['provider'])}")
print(f"account_id{w.get('account_id')}")
print(f"account_name{w.get('account_name') or ''}")
print(f"login_status{int(w.get('login_status') or 0)}")
print(f"created_at{_unix_to_iso(w.get('created_at'))}")
print(f"updated_at{_unix_to_iso(w.get('updated_at'))}")
if idx != len(rows) - 1:
print(sep_line)
print()
def cmd_key_del(key_id_str: str):
if not key_id_str.isdigit():
print(f"ERROR:INVALID_ID key_id 须为正整数,收到「{key_id_str}」。")
return
key_id = int(key_id_str)
key = get_key_by_id(key_id)
if not key:
print(f"ERROR:KEY_NOT_FOUND 未找到 ID={key_id} 的 API Key。")
return
delete_key(key_id)
label_str = f"{key['label']}" if key.get("label") else ""
print(f"✅ 已删除ID {key_id} | {LLM_PROVIDERS.get(key['provider'], {}).get('label', key['provider'])}{label_str} | {_mask_key(key['api_key'])}")
# ---------------------------------------------------------------------------
# health / version
# ---------------------------------------------------------------------------
def cmd_health():
ok = sys.version_info >= (3, 10)
sys.exit(0 if ok else 1)
def cmd_version():
print(json.dumps({"version": SKILL_VERSION, "skill": "llm-manager"}, ensure_ascii=False))
# ---------------------------------------------------------------------------
# CLI 解析
# ---------------------------------------------------------------------------
def _print_usage():
print("用法:")
print(" python main.py health")
print(" python main.py version")
print(" python main.py generate <平台名或account_id> \"<提示词>\"")
print(" python main.py add <平台> [API_Key] [--model 模型名] [--label 备注]")
print(" python main.py list [平台] [--limit 条数]")
print(" python main.py del <key_id>")
print()
print("支持的平台:" + provider_list_cn())
print()
print("generate 说明:")
print(" · 传入 account_id数字→ 指定账号网页模式(需先用 account-manager 登录)")
print(" · 传入平台名 → 自动选择:优先网页模式(免费),无可用账号时才用 API Key付费")
print()
print("兼容说明key add/list/del 旧写法仍可用。")
def main(argv=None) -> int:
args = argv if argv is not None else sys.argv[1:]
if not args:
_print_usage()
return 1
if args[0] in ("-h", "--help"):
_print_usage()
return 0
def _parse_list_args(rest_args):
platform_filter = None
limit = 10
i = 0
while i < len(rest_args):
if rest_args[i] == "--limit" and i + 1 < len(rest_args):
try:
limit = int(rest_args[i + 1])
except ValueError:
print(f"ERROR:CLI_KEY_LIST_BAD_LIMIT limit 必须是整数,收到「{rest_args[i + 1]}」。")
return None, None, 1
i += 2
else:
if platform_filter is None:
platform_filter = rest_args[i]
i += 1
return platform_filter, limit, 0
def _parse_add_args(rest_args):
if len(rest_args) < 1:
print("ERROR:CLI_ADD_MISSING_ARGS")
print("用法python main.py add <平台> [API_Key] [--model 模型] [--label 备注]")
return None, None, None, None, 1
platform_arg = rest_args[0]
api_key_arg = ""
model_arg = None
label_arg = ""
i = 1
if i < len(rest_args) and not rest_args[i].startswith("--"):
api_key_arg = rest_args[i]
i += 1
while i < len(rest_args):
if rest_args[i] == "--model" and i + 1 < len(rest_args):
model_arg = rest_args[i + 1]
i += 2
elif rest_args[i] == "--label" and i + 1 < len(rest_args):
label_arg = rest_args[i + 1]
i += 2
else:
i += 1
return platform_arg, api_key_arg, model_arg, label_arg, 0
cmd = args[0]
if cmd == "health":
cmd_health()
elif cmd == "version":
cmd_version()
elif cmd == "generate":
if len(args) < 3:
print("ERROR:CLI_GENERATE_MISSING_ARGS")
print("用法python main.py generate <平台名或account_id> \"<提示词>\"")
return 1
if not cmd_generate(args[1], args[2]):
return 1
elif cmd == "list":
platform_filter, limit, err = _parse_list_args(args[1:])
if err:
return err
cmd_key_list(platform_filter, limit=limit)
elif cmd == "add":
platform_arg, api_key_arg, model_arg, label_arg, err = _parse_add_args(args[1:])
if err:
return err
cmd_key_add(platform_arg, api_key_arg, model=model_arg, label=label_arg)
elif cmd == "del":
if len(args) < 2:
print("ERROR:CLI_DEL_MISSING_ARGS 用法python main.py del <key_id>")
return 1
cmd_key_del(args[1])
elif cmd == "key":
if len(args) < 2:
print("ERROR:CLI_KEY_MISSING_SUBCOMMAND 请指定子命令add / list / del")
return 1
sub = args[1]
if sub == "add":
platform_arg, api_key_arg, model_arg, label_arg, err = _parse_add_args(args[2:])
if err:
return err
cmd_key_add(platform_arg, api_key_arg, model=model_arg, label=label_arg)
elif sub == "list":
platform_filter, limit, err = _parse_list_args(args[2:])
if err:
return err
cmd_key_list(platform_filter, limit=limit)
elif sub == "del":
if len(args) < 3:
print("ERROR:CLI_KEY_DEL_MISSING_ARGS 用法python main.py key del <key_id>")
return 1
cmd_key_del(args[2])
else:
print(f"ERROR:CLI_UNKNOWN_KEY_SUB 未知 key 子命令「{sub}支持add / list / del")
return 1
else:
print(f"ERROR:CLI_UNKNOWN_COMMAND 未知命令「{cmd}」。")
_print_usage()
return 1
return 0
if __name__ == "__main__":
raise SystemExit(main())