Здравствуйте.

Думаю многие видели, что буквально только что, claude решили резко поменять политику и отключить работу своего решения в сторонних приложениях. То есть даже подписчики их платной подписки за 100 usd, среди которых был и я, теперь не могут использовать удобные безлимитные(в рамках лимитов) решения.

В основном это было сделано из-за безумно популярного агентского решения openclaw, которое в связке с claude sonnet/opus просто идеально. Работало это так. Подключаешься через oauth токен, для claude code и используешь все возможности подписки.

Теперь же claude выдает ошибку LLM request rejected. Они конечно на сайте выложили 100 usd, как компенсацию всем тем людям, что теперь будут вынуждены платить огромные суммы за extra usage, но это все равно не решение.

Идея

Так как работать, так невозможно, мною было решено реализовать прокси сервер для подключения к апи claude через браузерную сессию и сделать для него апи в совместимом openai формате. Чтобы легко можно было интегрировать, как сторонний провайдер в openclaw.

Реализация была поручена одному из лучших моих агентов, по имени Гарри c наибольшим кол-вом накопленного опыта по мере множества моих проектов и работ. Да пока что за их 100 usd бонусный лимит extra-usage.

Первое приближение

Первоначально мы сделали небольшой набросок, а именно я скопировал куки с браузера со своей сессией claude. Через антидетект браузер camoufox протестировали их на реальном сайте, далее посмотрели запросы и выяснили основные необходимые нам. И сделали перевод в формат openai-совместимый.

Написали на python. Для маскировки под браузер выбрана была библиотека curl_cffi с адекватным chrome tls отпечатком.

def make_session() -> cf_requests.Session:
    """Create a new curl_cffi session with Chrome TLS fingerprint."""
    cookies = get_cookies()
    session = cf_requests.Session(impersonate=IMPERSONATE)
    session.cookies.update(cookies)
    return session

def get_headers(cookies: dict) -> dict:
    return {
        "User-Agent": (
            "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 "
            "(KHTML, like Gecko) Chrome/136.0.0.0 Safari/537.36"
        ),
        "Origin": "https://claude.ai",
        "Referer": "https://claude.ai/new",
        "anthropic-device-id": cookies.get("anthropic-device-id", ""),
        "sec-fetch-dest": "empty",
        "sec-fetch-mode": "cors",
        "sec-fetch-site": "same-origin",
    }

Берем ответы с веб апи claude.

def claude_complete(
    prompt: str,
    internal_model: str,
) -> Generator[str, None, None]:
    """Sync generator that yields SSE lines from claude.ai completion."""
    cookies = get_cookies()
    org_id = cookies["lastActiveOrg"]
    headers = get_headers(cookies)

    session = make_session()

    # 1. Create conversation
    r = session.post(
        f"https://claude.ai/api/organizations/{org_id}/chat_conversations",
        json={"uuid": None, "name": ""},
        headers=headers,
        timeout=15,
    )
    r.raise_for_status()
    conv_id = r.json()["uuid"]
    log.info(f"Conv created: {conv_id} model={internal_model}")

    # 2. Stream completion
    payload = {
        "prompt": prompt,
        "model": internal_model,
        "timezone": "Europe/Moscow",
        "attachments": [],
        "files": [],
    }
    r2 = session.post(
        f"https://claude.ai/api/organizations/{org_id}/chat_conversations/{conv_id}/completion",
        json=payload,
        headers={**headers, "Accept": "text/event-stream"},
        stream=True,
        timeout=60,
    )
    r2.raise_for_status()
    for line in r2.iter_lines():
        yield line

API сделали на нескольких простых роутах. Отдаем по классическому openai.

# ──────────────────────────────── APP ─────────────────────────────────── #

def make_app() -> web.Application:
    app = web.Application()
    app.router.add_get("/v1/models", handle_models)
    app.router.add_get("/health", handle_health)
    app.router.add_post("/v1/chat/completions", handle_chat_completions)
    return app

if __name__ == "__main__":
    log.info(f"Starting claude-web-backend on :{PORT} (impersonating {IMPERSONATE})")
    log.info("Tool use support: ENABLED (prompt injection + <tool_call> parsing)")
    web.run_app(make_app(), host="127.0.0.1", port=PORT)

Затем добавили это как провайдер, в openclaw. Сработало! Но оказалось, что текст отлично работал, но вот tool use, то там нет. То есть самое нужное для меня, а именно прямые действия агента, оказались невозможными.

Насчет конфига openclaw заполняется так новый провайдер, а сами модели можно прописать в скрипте и тоже в конфиге.

"claude-web": {
        "baseUrl": "http://127.0.0.1:8787/v1",
        "apiKey": "dummy",
        "api": "openai-completions",
        "models": [
          {
            "id": "claude-sonnet-4-6-web",
            "name": "Claude Sonnet 4.6 (Web)",
            "reasoning": false,
            "input": [
              "text"
            ],
            "cost": {
              "input": 0,
              "output": 0,
              "cacheRead": 0,
              "cacheWrite": 0
            },
            "contextWindow": 200000,
            "maxTokens": 32000
            }]}

Промежуточная реализация

Tools use было решено реализовать следующим способом. Через специальную прослойку в виде сообщения нейросети в вебе, когда нужно их использовать, чтобы выдавало в нужном формате и потом парсинг этого формата в openclaw и работа с реальными действиями.

# ──────────────────────────────── TOOL USE PROMPT ─────────────────────── #

TOOL_SYSTEM_PROMPT = """You have access to tools. When you need to call a tool, output ONLY the tool call block — no explanation, no text before or after it:

<tool_call>
{{"name": "tool_name_here", "arguments": {{"param1": "value1", "param2": "value2"}}}}
</tool_call>

RULES:
- The JSON inside <tool_call> must be valid — no trailing commas, no comments
- You may output multiple <tool_call> blocks in sequence
- After receiving <tool_result> blocks, continue your response normally
- If you are not calling a tool, respond normally without any <tool_call> tags

Available tools:
{tools_json}"""

# Regex to extract tool_call blocks from response text
TOOL_CALL_RE = re.compile(
    r"<tool_call>\s*(.*?)\s*</tool_call>",
    re.DOTALL | re.IGNORECASE,
)

Это сработало, но возник нюанс, при совсем сложных действиях, сломалась разметка.

Конечная реализация

Итоговая реализация с проверкой результата и правками автоматом в случае проблем, отправляемыми нейросети веб, было конечным решением. Теперь все работало, как надо.

Добавили несколько проверок разметки и резервный xml парсинг. Теперь работает лучше.

def _try_fix_json(raw: str) -> str | None:
    """Attempt to auto-fix common JSON issues Claude produces."""
    # Remove trailing commas before } or ]
    fixed = re.sub(r",\s*([}\]])", r"\1", raw)
    # Remove // line comments
    fixed = re.sub(r"//[^\n]*", "", fixed)
    # Remove /* block comments */
    fixed = re.sub(r"/\*.*?\*/", "", fixed, flags=re.DOTALL)
    return fixed

def parse_tool_calls(text: str) -> tuple[list[dict], str]:
    """
    Parse <tool_call> blocks from text.
    Returns (tool_calls_list, remaining_text_without_blocks).
    Includes auto-repair for common JSON issues.
    """
    tool_calls = []

    for match in TOOL_CALL_RE.finditer(text):
        raw_json = match.group(1).strip()
        obj = None

        # 1) Try as-is
        try:
            obj = json.loads(raw_json)
        except json.JSONDecodeError as e:
            log.warning(f"tool_call JSON parse failed ({e}), trying auto-fix…")
            # 2) Try after auto-fix
            fixed = _try_fix_json(raw_json)
            try:
                obj = json.loads(fixed)
                log.info("tool_call JSON auto-fix succeeded")
            except json.JSONDecodeError as e2:
                log.error(f"tool_call JSON auto-fix also failed: {e2}\nRaw (first 300): {raw_json[:300]}")

        if obj is not None:
            name = obj.get("name", "")
            arguments = obj.get("arguments", {})
            tool_calls.append({
                "id": f"call_{uuid.uuid4().hex[:12]}",
                "type": "function",
                "function": {
                    "name": name,
                    "arguments": json.dumps(arguments, ensure_ascii=False),
                },
            })
            log.info(f"Parsed tool call: {name}({list(arguments.keys()) if isinstance(arguments, dict) else '...'})")

    # Remove tool_call blocks from text
    remaining = TOOL_CALL_RE.sub("", text).strip()
    return tool_calls, remaining

def inject_tool_results(messages: list[dict], tools: list[dict]) -> list[dict]:
    """
    Convert messages with tool_calls / tool results into Claude-understandable format.
    Uses XML-style <tool_result> blocks which Claude understands natively.

    OpenAI format:
      - role=assistant with tool_calls array  → reconstructed <tool_call> blocks
      - role=tool with tool_call_id/content  → <tool_result> XML block as user message
    """
    converted = []
    # Collect consecutive tool results to batch them into one user message
    pending_tool_results: list[str] = []

    def flush_tool_results():
        if pending_tool_results:
            combined = "\n".join(pending_tool_results)
            converted.append({"role": "user", "content": combined})
            pending_tool_results.clear()

    for m in messages:
        role = m.get("role", "")
        content = m.get("content", "")

        if role == "tool":
            # Flush any non-tool messages first
            tool_call_id = m.get("tool_call_id", "unknown")
            tool_name = m.get("name", "")  # some clients pass name
            name_attr = f' name="{tool_name}"' if tool_name else ""
            # Use XML format — Claude understands this from its training
            xml = (
                f'<tool_result id="{tool_call_id}"{name_attr}>\n'
                f'{content}\n'
                f'</tool_result>'
            )
            pending_tool_results.append(xml)

        elif role == "assistant" and m.get("tool_calls"):
            flush_tool_results()
            # Reconstruct <tool_call> blocks from tool_calls array
            blocks = []
            for tc in m["tool_calls"]:
                fn = tc.get("function", {})
                name = fn.get("name", "")
                args_str = fn.get("arguments", "{}")
                try:
                    args = json.loads(args_str)
                except Exception:
                    args = {"raw": args_str}
                blocks.append(
                    f"<tool_call>\n{json.dumps({'name': name, 'arguments': args}, ensure_ascii=False)}\n</tool_call>"
                )
            converted.append({"role": "assistant", "content": "\n".join(blocks)})

        else:
            flush_tool_results()
            converted.append(m)

    flush_tool_results()
    return converted

Исходники проекта залил на codeberg

Мысли

Возможно долго именно эта реализация не проработает. Скорее всего claude начнет что-то модифицировать, добавлять каптчи на сайт, ограничивать сессию веб по длительности. Но всегда можно будет модифицировать решение под свои нужды и добавить правки этих небольших проблем. Удачи вам!

P.S.

Если интересно, могу потом отдельно сделать статью по решению мной проблемы обхода в нейросетях claude их запретов и ограничений у моделей через создание узконаправленных агентов с полностью прописанной логикой и личностью и несколькими моими трюками.