Skill: ykaixu/model-setup (version 1.0.1)

--- name: model-setup description: 安全地管理 OpenClaw 模型配置。用于添加、测试和配置新模型到 models.json，包括 API key 验证、模型可访问性测试、工具调用功能检测、设置默认模型和配置到特定 agent。所有操作都会自动备份配置文件以确保安全。 --- # Model Manager 安全地管理 OpenClaw 模型配置。 ## 工作流程 ### 1. 收集模型配置参数 询问用户以下信息： **必需参数：** - **Provider ID**: 提供商标识符（如 `openai`, `anthropic`, 或自定义 ID） - **Base URL**: API 基础 URL（如 `https://api.openai.com/v1`） - **API Key**: API 密钥（格式通常为 `key_id:secret`） - **Model ID**: 模型 ID（如 `gpt-4`, `claude-3-opus`） - **Model Name**: 显示名称（如 `GPT-4 (OpenAI)`） - **Context Window**: 上下文窗口大小（token 数） - **Max Tokens**: 最大输出 token 数 **可选参数：** - **API Type**: API 类型（默认 `openai-completions`） - **Reasoning**: 是否支持推理（默认 `false`） - **Input Types**: 输入类型（默认 `["text"]`） - **Cost**: 成本配置（输入/输出/缓存读写） - **Streaming**: 是否支持流式输出（默认 `false`） ### 2. 验证 API Key 和模型可访问性 使用 `scripts/test_model.py` 测试模型配置： ```bash python3 scripts/test_model.py '<provider_config_json>' '<model_id>' [--test-tool-calling] [--test-streaming] ``` 测试内容包括： - API key 格式验证 - 模型可访问性测试 - 基本响应验证 - 工具调用功能测试（可选，使用 `--test-tool-calling`） - 流式输出功能测试（可选，使用 `--test-streaming`） 如果测试失败，告知用户错误原因并允许修正配置。 ### 3. 测试工具调用功能（可选） 询问用户是否需要测试工具调用功能。如果需要，发送一个包含工具调用的测试请求，验证模型是否正确处理工具调用。 ### 4. 确认配置 向用户展示完整的配置摘要，包括： - Provider ID 和 Base URL - Model ID 和 Name - Context Window 和 Max Tokens - 其他配置项 询问用户是否确认添加。 ### 5. 确认是否设为默认模型 询问用户是否将此模型设为默认模型。 ### 6. 确认是否配置给特定 agent 询问用户是否需要将此模型配置给特定 agent。如果需要，询问 agent 路径（如 `/home/yupeng/.openclaw/agents/main`）。 ### 7. 添加模型配置 使用 `scripts/add_model.py` 添加模型配置： ```bash python3 scripts/add_model.py '<config_path>' '<provider_id>' '<provider_config_json>' '<model_config_json>' [--default] [--agent <agent_path>] ``` 脚本会自动： - 备份原始配置文件（`.json.backup.YYYYMMDD_HHMMSS`） - 验证模型配置 - 添加或更新模型 - 设置默认模型（如果指定） - 配置到指定 agent（如果指定） - 如果失败，自动从备份恢复 ### 8. 验证结果 检查操作结果： - 如果成功，显示成功消息和备份文件路径 - 如果失败，显示错误信息并告知用户已从备份恢复 ## 配置文件位置 - **models.json**: `/home/yupeng/.openclaw/agents/main/agent/models.json` - **config.json** (默认模型): `/home/yupeng/.openclaw/agents/main/agent/config.json` - **Agent config.json**: `/home/yupeng/.openclaw/agents/<agent_name>/agent/config.json` ## 列出已配置的模型 使用 `scripts/list_models.py` 列出所有已配置的模型： ```bash # JSON 格式输出 python3 scripts/list_models.py # 格式化文本输出 python3 scripts/list_models.py --format # 指定配置文件路径 python3 scripts/list_models.py /path/to/models.json --format ``` 输出内容包括： - 提供商信息（ID、Base URL、API 类型） - 模型列表（ID、名称、上下文窗口、最大 token、推理支持） ## 安全注意事项 1. **始终备份**: 所有操作都会自动备份配置文件 2. **原子性写入**: 使用临时文件 + 原子替换，避免写入过程中断导致文件损坏 3. **验证优先**: 添加前先验证 API key 和模型可访问性 4. **错误恢复**: 如果操作失败，自动从备份恢复 5. **不覆盖**: 询问用户确认后再执行修改操作 ## 示例配置 ### OpenAI GPT-4 ```json { "provider_id": "openai", "provider_config": { "baseUrl": "https://api.openai.com/v1", "apiKey": "sk-xxx:yyy", "api": "openai-completions" }, "model_config": { "id": "gpt-4", "name": "GPT-4 (OpenAI)", "reasoning": false, "input": ["text"], "cost": { "input": 0.03, "output": 0.06, "cacheRead": 0.001, "cacheWrite": 0.004 }, "contextWindow": 128000, "maxTokens": 4096, "api": "openai-completions" } } ``` ### Anthropic Claude 3 ```json { "provider_id": "anthropic", "provider_config": { "baseUrl": "https://api.anthropic.com/v1", "apiKey": "sk-ant-xxx", "api": "anthropic-completions" }, "model_config": { "id": "claude-3-opus-20240229", "name": "Claude 3 Opus (Anthropic)", "reasoning": false, "input": ["text"], "cost": { "input": 0.015, "output": 0.075 }, "contextWindow": 200000, "maxTokens": 4096, "api": "anthropic-completions" } } ``` ## 错误处理 常见错误及解决方案： 1. **API key 格式错误**: 检查 API key 是否包含正确的分隔符 2. **模型不可访问**: 检查 Base URL、API key 和 Model ID 是否正确 3. **配置文件损坏**: 使用备份文件恢复 4. **权限不足**: 检查文件权限和用户权限

path: scripts/add_model.py
content: #!/usr/bin/env python3
"""
安全地添加模型配置到 models.json
"""
import json
import sys
import shutil
from pathlib import Path
from datetime import datetime


def backup_config(config_path):
    """
    备份配置文件

    Args:
        config_path: 配置文件路径

    Returns:
        Path: 备份文件路径
    """
    backup_path = config_path.with_suffix(f".json.backup.{datetime.now().strftime('%Y%m%d_%H%M%S')}")
    shutil.copy2(config_path, backup_path)
    return backup_path


def load_config(config_path):
    """
    加载配置文件

    Args:
        config_path: 配置文件路径

    Returns:
        dict: 配置内容
    """
    if not config_path.exists():
        return {"providers": {}}

    with open(config_path, 'r', encoding='utf-8') as f:
        return json.load(f)


def save_config(config_path, config):
    """
    保存配置文件

    Args:
        config_path: 配置文件路径
        config: 配置内容
    """
    # 先写入临时文件
    temp_path = config_path.with_suffix('.json.tmp')
    with open(temp_path, 'w', encoding='utf-8') as f:
        json.dump(config, f, indent=2, ensure_ascii=False)

    # 原子性替换
    temp_path.replace(config_path)


def validate_model_config(model_config):
    """
    验证模型配置

    Args:
        model_config: 模型配置

    Returns:
        tuple: (is_valid, error_message)
    """
    required_fields = ["id", "name", "contextWindow", "maxTokens"]

    for field in required_fields:
        if field not in model_config:
            return False, f"缺少必需字段: {field}"

    # 验证数值类型
    if not isinstance(model_config["contextWindow"], int) or model_config["contextWindow"] <= 0:
        return False, "contextWindow 必须是正整数"

    if not isinstance(model_config["maxTokens"], int) or model_config["maxTokens"] <= 0:
        return False, "maxTokens 必须是正整数"

    if model_config["maxTokens"] > model_config["contextWindow"]:
        return False, "maxTokens 不能超过 contextWindow"

    # 验证成本配置（如果存在）
    if "cost" in model_config:
        cost = model_config["cost"]
        cost_fields = ["input", "output", "cacheRead", "cacheWrite"]

        for field in cost_fields:
            if field in cost:
                if not isinstance(cost[field], (int, float)) or cost[field] < 0:
                    return False, f"cost.{field} 必须是非负数"

    return True, None


def add_model(config_path, provider_id, provider_config, model_config, set_as_default=False, agent_path=None):
    """
    添加模型配置

    Args:
        config_path: models.json 路径
        provider_id: 提供商ID
        provider_config: 提供商配置
        model_config: 模型配置
        set_as_default: 是否设为默认模型
        agent_path: agent 路径（如果需要配置给特定 agent）

    Returns:
        dict: 操作结果
    """
    result = {
        "success": False,
        "backup_path": None,
        "message": "",
        "error": None
    }

    try:
        # 验证模型配置
        is_valid, error_msg = validate_model_config(model_config)
        if not is_valid:
            result["error"] = error_msg
            return result

        # 备份配置文件
        result["backup_path"] = str(backup_config(config_path))

        # 加载现有配置
        config = load_config(config_path)

        # 添加或更新提供商
        if provider_id not in config["providers"]:
            config["providers"][provider_id] = {}

        # 更新提供商配置（保留现有配置）
        provider = config["providers"][provider_id]
        for key, value in provider_config.items():
            provider[key] = value

        # 确保有 models 列表
        if "models" not in provider:
            provider["models"] = []

        # 检查模型是否已存在
        model_exists = False
        for i, existing_model in enumerate(provider["models"]):
            if existing_model["id"] == model_config["id"]:
                # 更新现有模型
                provider["models"][i] = model_config
                model_exists = True
                result["message"] = f"模型 {model_config['id']} 已更新"
                break

        if not model_exists:
            # 添加新模型
            provider["models"].append(model_config)
            result["message"] = f"模型 {model_config['id']} 已添加"

        # 保存配置
        save_config(config_path, config)

        # 如果需要设为默认模型
        if set_as_default:
            default_config_path = config_path.parent / "config.json"
            if default_config_path.exists():
                try:
                    with open(default_config_path, 'r', encoding='utf-8') as f:
                        default_config = json.load(f)

                    # 设置默认模型
                    default_config["model"] = f"{provider_id}/{model_config['id']}"

                    # 保存默认配置
                    temp_path = default_config_path.with_suffix('.json.tmp')
                    with open(temp_path, 'w', encoding='utf-8') as f:
                        json.dump(default_config, f, indent=2, ensure_ascii=False)
                    temp_path.replace(default_config_path)

                    result["message"] += "，已设为默认模型"
                except Exception as e:
                    result["message"] += f"（设置默认模型失败: {str(e)}）"

        # 如果需要配置给特定 agent
        if agent_path:
            agent_config_path = Path(agent_path) / "agent" / "config.json"
            if agent_config_path.exists():
                try:
                    with open(agent_config_path, 'r', encoding='utf-8') as f:
                        agent_config = json.load(f)

                    # 设置 agent 的默认模型
                    agent_config["model"] = f"{provider_id}/{model_config['id']}"

                    # 保存 agent 配置
                    temp_path = agent_config_path.with_suffix('.json.tmp')
                    with open(temp_path, 'w', encoding='utf-8') as f:
                        json.dump(agent_config, f, indent=2, ensure_ascii=False)
                    temp_path.replace(agent_config_path)

                    result["message"] += f"，已配置给 agent: {agent_path}"
                except Exception as e:
                    result["message"] += f"（配置 agent 失败: {str(e)}）"

        result["success"] = True

    except Exception as e:
        result["error"] = f"添加模型失败: {str(e)}"
        # 如果有备份，尝试恢复
        if result["backup_path"]:
            try:
                shutil.copy2(result["backup_path"], config_path)
                result["message"] += "（已从备份恢复）"
            except:
                pass

    return result


if __name__ == "__main__":
    if len(sys.argv) < 4:
        print("用法: add_model.py <config_path> <provider_id> <provider_config_json> <model_config_json> [--default] [--agent <agent_path>]")
        sys.exit(1)

    config_path = Path(sys.argv[1])
    provider_id = sys.argv[2]
    provider_config = json.loads(sys.argv[3])
    model_config = json.loads(sys.argv[4])

    set_as_default = "--default" in sys.argv
    agent_path = None
    if "--agent" in sys.argv:
        idx = sys.argv.index("--agent")
        if idx + 1 < len(sys.argv):
            agent_path = sys.argv[idx + 1]

    result = add_model(config_path, provider_id, provider_config, model_config, set_as_default, agent_path)
    print(json.dumps(result, indent=2, ensure_ascii=False))
    sys.exit(0 if result["success"] else 1)

path: scripts/list_models.py
content: #!/usr/bin/env python3
"""
列出所有配置的模型
"""
import json
import sys
from pathlib import Path


def list_models(config_path):
    """
    列出所有配置的模型

    Args:
        config_path: models.json 路径

    Returns:
        dict: 列表结果
    """
    result = {
        "success": False,
        "providers": [],
        "total_models": 0,
        "error": None
    }

    try:
        if not config_path.exists():
            result["error"] = f"配置文件不存在: {config_path}"
            return result

        with open(config_path, 'r', encoding='utf-8') as f:
            config = json.load(f)

        if "providers" not in config:
            result["error"] = "配置文件缺少 providers 字段"
            return result

        providers = config["providers"]
        total_models = 0

        for provider_id, provider_config in providers.items():
            models = provider_config.get("models", [])
            provider_info = {
                "provider_id": provider_id,
                "base_url": provider_config.get("baseUrl", "N/A"),
                "api_type": provider_config.get("api", "N/A"),
                "models": []
            }

            for model in models:
                model_info = {
                    "id": model.get("id", "N/A"),
                    "name": model.get("name", "N/A"),
                    "context_window": model.get("contextWindow", "N/A"),
                    "max_tokens": model.get("maxTokens", "N/A"),
                    "reasoning": model.get("reasoning", False),
                    "full_id": f"{provider_id}/{model.get('id', 'N/A')}"
                }
                provider_info["models"].append(model_info)
                total_models += 1

            result["providers"].append(provider_info)

        result["total_models"] = total_models
        result["success"] = True

    except json.JSONDecodeError as e:
        result["error"] = f"配置文件解析失败: {str(e)}"
    except Exception as e:
        result["error"] = f"列出模型失败: {str(e)}"

    return result


def format_output(result):
    """
    格式化输出结果

    Args:
        result: list_models 的结果

    Returns:
        str: 格式化的输出
    """
    if not result["success"]:
        return f"错误: {result['error']}"

    output = []
    output.append(f"总计 {result['total_models']} 个模型\n")

    for provider in result["providers"]:
        output.append(f"提供商: {provider['provider_id']}")
        output.append(f"  Base URL: {provider['base_url']}")
        output.append(f"  API 类型: {provider['api_type']}")
        output.append(f"  模型数量: {len(provider['models'])}")
        output.append("  模型列表:")

        for model in provider["models"]:
            output.append(f"    - {model['name']}")
            output.append(f"      ID: {model['id']}")
            output.append(f"      完整 ID: {model['full_id']}")
            output.append(f"      上下文窗口: {model['context_window']}")
            output.append(f"      最大 [REDACTED_SECRET]'max_tokens']}")
            output.append(f"      推理支持: {'是' if model['reasoning'] else '否'}")
            output.append("")

    return "\n".join(output)


if __name__ == "__main__":
    config_path = Path("/home/yupeng/.openclaw/agents/main/agent/models.json")
    use_format = False

    # 解析参数
    args = sys.argv[1:]
    for i, arg in enumerate(args):
        if arg == "--format":
            use_format = True
        elif not arg.startswith("--"):
            config_path = Path(arg)

    result = list_models(config_path)

    # 默认输出 JSON，使用 --format 时输出格式化文本
    if use_format:
        print(format_output(result))
    else:
        print(json.dumps(result, indent=2, ensure_ascii=False))

    sys.exit(0 if result["success"] else 1)

path: scripts/test_model.py
content: #!/usr/bin/env python3
"""
测试模型配置是否有效
"""
import json
import sys
import subprocess
from pathlib import Path

def test_model_config(provider_config, model_id, test_tool_calling=False, test_streaming=False):
    """
    测试模型配置是否有效

    Args:
        provider_config: 提供商配置
        model_id: 模型ID
        test_tool_calling: 是否测试工具调用
        test_streaming: 是否测试流式输出

    Returns:
        dict: 测试结果
    """
    result = {
        "success": False,
        "api_key_valid": False,
        "model_accessible": False,
        "tool_calling": False,
        "streaming": False,
        "tool_calling_error": None,
        "streaming_error": None,
        "error": None
    }

    try:
        # 检查 API key 格式
        [REDACTED_SECRET]"apiKey", "")
        if not [REDACTED_SECRET]"error"] = "API key 为空"
            return result

        # 更灵活的 API key 格式验证
        # 有些提供商使用 key_id:secret 格式，有些使用单一 token
        # 只要 API key 不为空且长度合理，就认为有效
        if len(api_key) < 10:
            result["error"] = "API key 格式不正确（长度太短）"
            return result

        result["api_key_valid"] = True

        # 尝试调用模型进行简单测试
        # 这里使用 curl 进行测试
        base_url = provider_config.get("baseUrl", "")
        api_type = provider_config.get("api", "openai-completions")

        if not base_url:
            result["error"] = "baseUrl 为空"
            return result

        # 构建测试请求
        test_url = f"{base_url}/chat/completions"

        # 准备测试 payload
        payload = {
            "model": model_id,
            "messages": [
                {"role": "user", "content": "测试"}
            ],
            "max_tokens": 10
        }

        # 使用 curl 测试
        curl_cmd = [
            "curl", "-s", "-X", "POST", test_url,
            "-H", "Content-Type: application/json",
            "-H", f"Authorization: Bearer {api_key}",
            "-d", json.dumps(payload),
            "--connect-timeout", "10",
            "--max-time", "30"
        ]

        try:
            output = subprocess.check_output(curl_cmd, stderr=subprocess.PIPE, text=True)
            response = json.loads(output)

            # 检查响应
            if "choices" in response and len(response["choices"]) > 0:
                result["model_accessible"] = True
                result["success"] = True
            else:
                result["error"] = f"模型响应异常: {response.get('error', '未知错误')}"
        except subprocess.CalledProcessError as e:
            result["error"] = f"请求失败: {e.stderr}"
        except json.JSONDecodeError as e:
            result["error"] = f"响应解析失败: {str(e)}"

        # 测试工具调用功能
        if test_tool_calling and result["model_accessible"]:
            try:
                tool_payload = {
                    "model": model_id,
                    "messages": [
                        {"role": "user", "content": "现在几点了？"}
                    ],
                    "tools": [
                        {
                            "type": "function",
                            "function": {
                                "name": "get_time",
                                "description": "获取当前时间",
                                "parameters": {
                                    "type": "object",
                                    "properties": {},
                                    "required": []
                                }
                            }
                        }
                    ],
                    "max_tokens": 50
                }

                tool_curl_cmd = [
                    "curl", "-s", "-X", "POST", test_url,
                    "-H", "Content-Type: application/json",
                    "-H", f"Authorization: Bearer {api_key}",
                    "-d", json.dumps(tool_payload),
                    "--connect-timeout", "10",
                    "--max-time", "30"
                ]

                tool_output = subprocess.check_output(tool_curl_cmd, stderr=subprocess.PIPE, text=True)
                tool_response = json.loads(tool_output)

                # 检查是否有 tool_calls
                if "choices" in tool_response and len(tool_response["choices"]) > 0:
                    message = tool_response["choices"][0].get("message", {})
                    if "tool_calls" in message and len(message["tool_calls"]) > 0:
                        result["tool_calling"] = True
                    else:
                        result["tool_calling_error"] = "模型未返回 tool_calls"
                else:
                    result["tool_calling_error"] = "工具调用测试响应异常"
            except subprocess.CalledProcessError as e:
                result["tool_calling_error"] = f"工具调用请求失败: {e.stderr}"
            except json.JSONDecodeError as e:
                result["tool_calling_error"] = f"工具调用响应解析失败: {str(e)}"
            except Exception as e:
                result["tool_calling_error"] = f"工具调用测试出错: {str(e)}"

        # 测试流式输出功能
        if test_streaming and result["model_accessible"]:
            try:
                stream_payload = {
                    "model": model_id,
                    "messages": [
                        {"role": "user", "content": "测试"}
                    ],
                    "max_tokens": 10,
                    "stream": True
                }

                stream_curl_cmd = [
                    "curl", "-s", "-X", "POST", test_url,
                    "-H", "Content-Type: application/json",
                    "-H", f"Authorization: Bearer {api_key}",
                    "-d", json.dumps(stream_payload),
                    "--connect-timeout", "10",
                    "--max-time", "30"
                ]

                stream_output = subprocess.check_output(stream_curl_cmd, stderr=subprocess.PIPE, text=True)

                # 检查是否是 SSE 格式（以 "data: " 开头的多行）
                lines = stream_output.strip().split('\n')
                if len(lines) > 0 and any(line.startswith("data: ") for line in lines):
                    result["streaming"] = True
                else:
                    result["streaming_error"] = "响应不是 SSE 格式"
            except subprocess.CalledProcessError as e:
                result["streaming_error"] = f"流式输出请求失败: {e.stderr}"
            except Exception as e:
                result["streaming_error"] = f"流式输出测试出错: {str(e)}"

    except Exception as e:
        result["error"] = f"测试过程出错: {str(e)}"

    return result


if __name__ == "__main__":
    if len(sys.argv) < 3:
        print("用法: test_model.py <provider_config_json> <model_id> [--test-tool-calling] [--test-streaming]")
        sys.exit(1)

    provider_config = json.loads(sys.argv[1])
    model_id = sys.argv[2]
    test_tool_calling = "--test-tool-calling" in sys.argv
    test_streaming = "--test-streaming" in sys.argv

    result = test_model_config(provider_config, model_id, test_tool_calling, test_streaming)
    print(json.dumps(result, indent=2, ensure_ascii=False))
    sys.exit(0 if result["success"] else 1)

Security scan verdicts — ClawScan: suspicious | static analysis: clean | VirusTotal: suspicious | SkillSpector: suspicious (MEDIUM)