""" llm-manager 主入口 CLI。 子命令: health 快速离线健康检查 version 输出版本 JSON add [api_key] 添加 API Key(不传 api_key 则走网页账号关联) key list [platform] 列出 Key(打码) key del 删除 Key generate 生成内容(优先网页模式,备用 API Key 模式) "" 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 ") 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} \"\"") 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 Key:ID {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 Key:python main.py add \"API Key\" [--model 模型名]") print("添加网页账号关联:python main.py add ") 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_type:api_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_type:web_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 ") 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 ") 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 ") 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())