VS Code + Cline で ”Devin 風”開発を行う方法⑧ - エスカレーション準備編【Cline CLI + LiteLLM】

前回は「LangGraph」を使用して、Kanban 内部で行っていたループ処理を外部ループに移動しました。

今回は「LiteLLM」と呼ばれる、複数のLLM (大規模言語モデル) を統一的に扱うための軽量なプロキシ / ラッパーライブラリを使用して、Kanban 側では使用する「LLM モデル名を変更するだけ」でローカルとクラウド LLM の切り替えが行える仕組みを整えます。

LiteLLM を使用すると OpenAI 互換の API で、いろんな AI モデルをまとめて使えるようになります。

今回の記事では Kanban と Cline CLI から LiteLLM プロキシを使用して Ollama のローカルモデルを使用できるように設定していきます。

この記事で使用している実行環境は以下のようになっています。

  • VS Code (Windows 版)・・・バージョン 1.134.0
  • Node.js (Windows 版)・・・バージョン 24.15.0
  • Cline・・・バージョン 3.0.48
  • Kanban・・・バージョン 0.1.70
  • Python・・・バージョン 3.12.13
  • LangGraph・・・バージョン 1.2.10
  • LiteLLM・・・バージョン 1.98.0

※本記事でご紹介している内容は、確立された手法ではなく著者の独自の解釈に基づいています。その点をご理解のうえご参照ください。

※(2026/08/28 追記) LiteLLM で「ollama_chat/」を設定すると Tool Calling 履歴が欠落する現象への対応を追加しました。 

※(2026/08/30 追記) LiteLLM の ollama_chat/ にヒーリング機能を追加しました。 

動作環境について
LiteLLM パッケージのインストール

※LiteLLM Proxy は複数の LLM プロバイダをまとめて扱える反面、API キーなどの認証情報を一元管理する構成になりやすく、万一侵害された場合の影響が大きくなります。LiteLLM では過去に認証バイパスやコード実行、サプライチェーン侵害などのセキュリティ問題も報告されているため、実運用では最新のセキュリティ情報を確認し、修正版への更新をおすすめします。また、LiteLLM Proxy をインターネットへ直接公開せず、必要に応じて認証やリバースプロキシなどでアクセスを制限してください。

基本的には外部公開を避け、localhost や信頼できるローカルネットワーク内で利用することをおすすめします。

今回も「Miniconda」と呼ばれる Python の環境管理ツールを使用しています。Miniconda のインストール方法はこちらの記事をご参照ください。

まず Anaconda PowerShell Prompt と呼ばれるターミナル画面を起動します。普通の PowerShell ターミナル画面でも問題ありませんが、専用のターミナルはプロンプトに現在の環境を表示してくれるのでわかり易いかもしれません。

Anaconda PowerShell Prompt に以下のように入力して、前回作成した langgraphv1.2 環境を有効化します。

conda activate langgraphv1.2

次に以下のように入力して、LiteLLM プロキシ版パッケージをインストールします。

pip install 'litellm[proxy]'

今回は使用しませんが、ライブラリ版の LiteLLM を使用したい場合は以下のパッケージをインストールします。LangGraph などから直接 LiteLLM 機能を利用できます。今回使用するプロキシ版はローカルネットワーク経由で利用します。

pip install litellm

インストールが完了したら、以下のように入力してバージョンを確認しておきます。

litellm --version

以上でパッケージのインストールは終了です。次に動作テストを行います。

LiteLLM の動作テスト

VS Code で任意のフォルダを開きます。「config.yaml」というファイルを作成して以下の内容を記述します。

Ollama にインストールされている任意のモデルが使用できますが、本記事の構成では「ollama_chat/」を追加してください。(一般的なサンプルでは「ollama/」が使用されています。「ollama_chat/」を使用している理由については「Cline CLI 設定」セクションをご覧ください。)

Ollama がリモート PC 上で実行されている場合は「api_base」項目にリモート PC の IP アドレスを設定してください。

model_list:
  - model_name: ollama-gpt-oss:20b
    litellm_params:
      model: ollama_chat/gpt-oss:20b
      api_base: http://localhost:11434

設定が完了したら、VS Code でターミナル画面を開いて以下のように入力します。

litellm --config config.yaml

正常に起動すると以下のような画面が表示されます。LiteLLM プロキシはデフォルトでは「localhost:4000」で起動します。

VS Code でもう一つターミナル画面を開いて、以下のコマンドを入力します。「model」名は LiteLLM の config.yaml ファイルで設定した「model_name」項目 (エイリアス) を入力します。

curl.exe http://localhost:4000/v1/chat/completions `
  -H "Content-Type: application/json" `
  -d '{
    "model": "ollama-gpt-oss:20b",
    "messages": [{"role": "user", "content": "Hello"}]
  }'

以下のような応答が返ってきたら、正常に動作しています。

ご使用の PC に curl というアプリが入っていない場合は、以下の PowerShell スクリプトでテストできます。

$body = @{
    model = "ollama-gpt-oss:20b"
    messages = @(
        @{
            role = "user"
            content = "Hello"
        }
    )
} | ConvertTo-Json -Depth 10

$response = Invoke-RestMethod `
    -Uri "http://localhost:4000/v1/chat/completions" `
    -Method Post `
    -ContentType "application/json" `
    -Body $body

$response.choices[0].message.content

以下のような応答が返ってくれば正常に動作しています。

以上で環境設定は終了です。

Kanban 設定

次に Kanban で LiteLLM を使用できるように設定していきます。Kanban を起動して

  1. 右上にある設定ボタンを押します。
  2. Settings 画面の左側メニューにある「Cline」を選択します。
  3. API provider から「Ollama」を選択します。
  4. Base URL に「http://localhost:4000/v1」(LiteLLM のアドレス) を入力します。

その他 API key は入力しなくても平気なようです。任意の文字列が入力できます。また、「Model ID」はこの段階では選択できません。次の操作を行うと選択できるようになります。

Settings 画面にある「Edit」をクリックします。

表示された画面で

  1. Model source URL に「http://localhost:4000/v1/models」と入力します。
  2. 「Update Provider」ボタンを押して閉じます。

以上で LiteLLM に登録されている「エイリアス名」が選択可能になります。Model ID ドロップダウンから使用したモデルを選択して「Save」ボタンを押すと設定されます。

Kanban Agent のプロンプト欄に「こんにちは」などと入力して実行してみると、正常に動作しているようです。

API provider は Ollama ですが、実体は LiteLLM プロキシなので以下のようなクラウド LLM の設定も追加しておくと

Kanban からはモデル名を変更するだけでクラウド LLM が利用できるようになります。

スクリプトからは「--cline-model」オプションを使用してモデル名を変更できます。必要であれば「--cline-provider」オプションでプロバイダも変更できるようです。

# タスク作成コマンド
kanban task create --prompt "test" --cline-model "gemma4:e4b"

# タスク修正コマンド
kanban task update --task-id "12345" --cline-model "gemma4:e4b"

以上で Kanban の設定は終了です。

なぜ LiteLLM プロバイダを使用しないのか?

ここで少し不思議に思われるかもしれませんが、LiteLLM を使用しているにもかかわらず、Kanban 側では API provider に「Ollama」を指定しています。

もともと Kanban には「Litellm」という API provider が用意されています。

しかし、このプロバイダを使用すると「/v1/responses」という API が使用されるようです。

今回の環境では「/v1/chat/completions」を使用した場合は正常に動作しましたが、Kanban の LiteLLM プロバイダ から「/v1/responses」を使用すると、筆者の環境では「text part ... not found」というエラーが発生しました。

プロバイダの定義ファイルは「%USERPROFILE%/.cline/data/settings/providers.json」にあるので、OpenAI Chat Completions 形式を使用する独自 provider を追加する方法も試しましたが、Kanban 実行時に「Unknown or disabled provider」というエラーが発生して、あらかじめ登録されていないプロバイダは使用できませんでした。

そこで、OpenAI 互換 API を利用できる既存の Ollama provider の Base URL を LiteLLM に向けてみると現時点では上手く動作しましたので、この方式を採用しました。

Cline CLI 設定

せっかくなので「Cline CLI」からも LiteLLM を使用できるように設定していきます。

Cline CLI は、Cline のコーディングエージェント機能をコマンドラインから利用するためのツールです。プロンプトを渡すと、LLM がコードを調査し、必要に応じてファイル編集やコマンド実行などのツールを使用して作業を進めます。VS Code の画面操作を必要としないため、LangGraph や PowerShell、Python などの外部処理から呼び出して、自動化された開発ワークフローに組み込むことができます。

ファイル編集なども行えるので、試しに VS Code のターミナル画面から以下のコマンドを 実行して「test.md」というファイルが作成できるか試してみます。

cline "create test.md file."

何かエラーが発生しています。

結論から先に書きます。以下のオプションを追加すると正常に動作します。

  • 「-P」オプションで「litellm」プロバイダを指定する。
  • 「-m」オプションで LiteLLM のモデル (エイリアス) を指定する。
cline -P litellm -m "ollama-gpt-oss:20b" "create test.md file."

実行結果は以下のようになります。「test.md」というファイルが作成されています。

ここまでたどり着くまでかなり紆余曲折しましたので、備忘録として検証結果を記述します。

オプションが無いとなぜ動作しないのか?

先に結論を書くと、Cline CLI と Kanban はデフォルトで設定情報を共有しており、Kanban 側で Ollama provider の Base URL を LiteLLM に変更した設定が Cline CLI にも影響していました。

Kanban ではこの設定でも動作しますが、Cline CLI でははじかれてしまうようです。以下は検証した結果になります。

ターミナル画面に「cline config」と入力して Cline の設定画面を表示します。「Provider」と「Model」が Kanban に設定した値と同じなので、Kanban と同じ設定ファイルを使用していると思われます。

一旦 Kanban 設定を正規の Ollama 設定に戻しておきます。モデルも本当のモデルに戻します。

最初にエラーになったコマンドを再度実行してみます。

今度は成功しました。「test.md」ファイルが作成されています。筆者の環境では、Ollama provider に LiteLLM の Base URL を設定した構成のままでは Cline CLI が正常に動作しないようです。

他のモデルも試してみます。モデルが大きくて起動に時間がかかるためか、「Ollama request timed out after 30 seconds」というタイムアウトエラーが発生しています。

Cline CLI にはタイムアウトオプションがあるので、長めに設定してみましたが変わらないようです。

そこで LiteLLM に同じモデルのエントリーを追加して、プロバイダを LiteLLM に変更したところ正常に実行できました。タイムアウトも発生しませんでした。

Cline CLI では LiteLLM プロバイダを直接使用した方が良さそうです。

なぜ「ollama_chat/」を使うのか?

こちらも結論から先に書きます。LiteLLM で Ollama を使用する場合通常はモデル名の先頭に「ollama/」を追加しますが、筆者の環境では「ollama/」を使用した場合、「/api/generate」が使用され、Cline のファイル修正などの Tool Calling が正常に動作しませんでした。一方、「ollama_chat/」を使用すると 「/api/chat」が使用され、ツールが正常に実行されました。

以下は検証結果になります。

まず、「config.yaml」設定でモデルの先頭を「ollama/」に変更します。

model_list:
  - model_name: ollama-gpt-oss:20b
    litellm_params:
      model: ollama/gpt-oss:20b
      api_base: http://192.168.11.50:11434

VS Code のターミナル画面に以下のように入力して、LiteLLM をデバッグモードで起動します。同時に「litellm.log」にも内容を書き出します。

$env:PYTHONUTF8 = "1"
$env:PYTHONIOENCODING = "utf-8"

litellm --config config.yaml --detailed_debug 2>&1 |
    Tee-Object -FilePath litellm.log

VS Code で別のターミナル画面を起動して Cline CLI コマンドを実行します。

cline -P litellm -m "ollama-gpt-oss:20b" "create test.md file."

ログが非常に長いので特徴的な部分だけ抜粋します。モデルの先頭が ollama/ の場合は「/api/generate」という API が使用されるようです。ツール定義もプロンプトの中に埋め込まれています。

POST Request Sent from LiteLLM:
curl -X POST \
http://192.168.11.50:11434/api/generate \
-d '{'model': 'gpt-oss:20b', 'prompt': '### System:\n

...

### User:\n
create test.md file.\n
\n
', 'options': {}, 'stream': True, 'format': 'json', 'images': []}'

Cline CLI 側もおそらく期待した形式と異なるメッセージのため、ファイルを作成するツールが呼び出されていないようです。

今度はモデル名の先頭を「ollama_chat/」に変更して、LiteLLM を再起動します。

model_list:
  - model_name: ollama-gpt-oss:20b
    litellm_params:
      model: ollama_chat/gpt-oss:20b
      api_base: http://192.168.11.50:11434

先ほどと同じようにログを見ると今度は Ollama の「/api/chat」にリクエストが送信されています。さらに、Cline から渡されたツール定義が tools フィールドとして独立して送信されています。

「ollama/」使用時はツール情報がプロンプト側へ変換されていましたが、「ollama_chat/」では Ollama の Chat API に Tool Calling 用の情報として渡されていることが確認できました。

POST Request Sent from LiteLLM:
curl -X POST \
http://192.168.11.50:11434/api/chat \
-d '{
  "model": "gpt-oss:20b",
  "messages": [
    {"role": "user", "content": "create test.md file."}
  ],
  "stream": true,
  "tools": [
    {
      "type": "function",
      "function": {
        "name": "read_files",
        ...
      }
    },
    {
      "type": "function",
      "function": {
        "name": "apply_patch",
        ...
      }
    }
  ]
}'

Cline CLI 側でもツールが実行されて「test.md」ファイルが作成されているようです。

ollama_chat/ の Tool Calling 履歴が欠落する現象への対応

筆者の環境では、LiteLLM の「ollama_chat/」を経由して複数ターンの Tool Calling を行うと、最初の Tool Call 実行後に処理が停止することがありました。クラウド LLM では同様の現象は確認できませんでした。

調べたところ LiteLLM の「ollama_chat/」の変換で Kanban から送られてきたデータと食い違いがあるようなので LiteLLM に手動でローカルパッチを当てます。

以下は検証と修正方法になっています。

クラウドとローカルの違いは次のように LiteLLM の ollama_chat を通るか通らないかの違いだけなので ollama_chat 変換に関係しているファイルを調べます。

# クラウド LLM
Kanban → LiteLLM (openai/, chatgpt/, ...) → クラウド LLM

# ローカル LLM
Kanban → LiteLLM (ollama_chat/) → Ollama

LiteLLM の ollama_chat に関連する変換ファイルは以下の場所にあります。(Miniconda を「C:\miniconda3」にインストールした場合)

C:\miniconda3\Lib\site-packages\litellm\llms\ollama\chat\transformation.py

このファイルの「transform_request」という関数の 323 行目辺りにある、「return data」文の前に以下の print 文を追加して違いを表示します。

        print("=== OLLAMA_CHAT ORIGINAL MESSAGES ===")
        print(json.dumps(messages, ensure_ascii=False, indent=2, default=str))

        print("=== OLLAMA_CHAT TRANSFORMED MESSAGES ===")
        print(json.dumps(new_messages, ensure_ascii=False, indent=2, default=str))

実行してみると、Kanban からは「tool_calls」や「tool_call_id」が送られてきますが、下側の変換されたデータからは抜け落ちているようです。このため Ollama 側では過去の Tool Calling の対応関係を正しく参照できなくなり、次の Tool Call を返さず処理を終了する一因になっていると考えられます。

=== OLLAMA_CHAT ORIGINAL MESSAGES ===
[
  {
    "role": "system",
    "content": "You are Cline, an AI coding agent. ...
  },
  {
    "role": "user",
    "content": "# worker-workflow\r\n\r\n## 実行継続 ...
  },
  {
    "role": "assistant",
    "content": "Worker ワークフローを開始します。まず初期化テストを実行します。",
    "reasoning_content": "このワークフローは worker タスクを実行するための手順を示しています。...
    "tool_calls": [
      {
        "function": {
          "name": "run_commands",
        ...
        }
      }
    ]
  },
  {
    "role": "tool",
    "tool_call_id": "6b1fc484-554c-4d47-8277-1ff067c62d6f",
    "content": "[{\"query\":\"python ./kanban/python/dispatch/dispatcher.py --help ... }]
  }
]
...
=== OLLAMA_CHAT TRANSFORMED MESSAGES ===
[
  {
    "role": "system",
    "content": "You are Cline, an AI coding agent. ...
    "images": []
  },
  {
    "role": "user",
    "content": "# worker-workflow\r\n\r\n## 実行継続 ...
    "images": []
  },
  {
    "role": "assistant",
    "thinking": "このワークフローは worker タスクを実行するための手順を示しています。まず、初期化テストを行い、その後タスクを初期化し、コード修正を実施し、チェックを行い、最後に終了処理を行う必要があります。\n\nまず、初期化テストから始めましょう:\n1. Python テスト: `python ./kanban/python/dispatch/dispatcher.py --help`\n2. PowerShell テスト: `powershell -NoProfile -ExecutionPolicy Bypass -File ./kanban/scripts/kanban/util/read-worktree-file.ps1 ./.clinerules/AGENTS.md`\n\nこれらが成功してから次に進みます。",
    "content": "Worker ワークフローを開始します。まず初期化テストを実行します。",
    "images": []
  },
  {
    "role": "tool",
    "content": "[{\"query\":\"python ./kanban/python/dispatch/dispatcher.py --help ...}]
    "images": []
  }
]

先ほど追加した print 文の上に以下の修正コードを追加します。

※この修正は LiteLLM 1.98.0 で筆者が確認した現象に対する暫定的なローカルパッチです。LiteLLM のアップデートによってファイルが上書きされたり、内部実装が変更されたりする可能性があります。

        for i, original in enumerate(messages):
            if i >= len(new_messages):
                continue

            transformed = new_messages[i]

            if original.get("role") == "assistant" and original.get("tool_calls"):
                converted_tool_calls = []

                for tc in original["tool_calls"]:
                    function = tc.get("function", {})
                    arguments = function.get("arguments", {})

                    if isinstance(arguments, str):
                        try:
                            arguments = json.loads(arguments)
                        except Exception:
                            pass

                    converted_tool_calls.append({
                        "function": {
                            "name": function.get("name"),
                            "arguments": arguments,
                        }
                    })

                transformed["tool_calls"] = converted_tool_calls

            if original.get("role") == "tool" and original.get("tool_call_id"):
                transformed["tool_call_id"] = original["tool_call_id"]

実行して確認してみるとうまく動作しているようです。

ollama_chat/ にヒーリング機能を追加

ローカル LLM を使用して Kanban から Python や PowerShell コマンドを呼び出すと、コマンド呼び出しが失敗することが多いようです。

コマンド実行用の Tool Call はローカル LLM が生成し、その内容が LiteLLM を経由して Kanban (Cline) に返された後、実際のコマンドとして実行されます。

Kanban ← LiteLLM (ollama_chat/) ← Ollama (ローカル LLM)

正常なコマンド形式は以下のようになっています。

{
  "commands": [
    {
      "command": "python",
      "args": [
        "./kanban/python/dispatch/dispatcher.py",
        "--help"
      ]
    }
  ]
}

しかし実際には以下のように単なる文字列として生成される場合が多いようです。このためローカル LLM が正常なコマンド形式を生成してくれるまで何度もやり取りが行われます。

{
  "commands": [
    {
      "command": "python ./kanban/python/dispatch/dispatcher.py --help"
    }
  ]
}

以前の構成では触ることができませんでしたが、今回 LiteLLM を導入したため誤ったコマンド形式を Kanban に送信する前に修正 (ヒーリング) することが可能になりました。

ローカルパッチを作成してもいいのですが、かなり修正する必要があるのでパッチファイルを作成して、オリジナルの LiteLLM ファイルからは最小限の変更で機能を呼び出せるように修正します。

LiteLLM の変換ファイル「transformation.py」と同じフォルダに「cline_compat.py」というファイルを作成して以下の内容を記述します。結構長いので折りたたんで表示しています。

cline_compat.py
import os
import re
import json
import shlex
from typing import Any, List, Optional

DEBUG = os.environ.get(
    "LITELLM_CLINE_COMPAT_DEBUG",
    "0",
) == "1"

def patch_request_messages(
    messages: list,
    new_messages: list,
) -> list:
    """
    LiteLLM ollama_chat の request 変換時に、
    Tool Calling 履歴が欠落する問題を補正する。
    """

    for i, original in enumerate(messages):
        if i >= len(new_messages):
            continue

        transformed = new_messages[i]

        # assistant.tool_calls を保持
        if (
            original.get("role") == "assistant"
            and original.get("tool_calls")
        ):
            converted_tool_calls = []

            for tc in original["tool_calls"]:
                function = tc.get("function", {})
                arguments = function.get("arguments", {})

                if isinstance(arguments, str):
                    try:
                        arguments = json.loads(arguments)
                    except Exception:
                        pass

                converted_tool_calls.append(
                    {
                        "function": {
                            "name": function.get("name"),
                            "arguments": arguments,
                        }
                    }
                )

            transformed["tool_calls"] = converted_tool_calls

        # tool.tool_call_id を保持
        if (
            original.get("role") == "tool"
            and original.get("tool_call_id")
        ):
            transformed["tool_call_id"] = original["tool_call_id"]

    if DEBUG:
        print("=== OLLAMA_CHAT ORIGINAL MESSAGES ===")
        print(json.dumps(messages, ensure_ascii=False, indent=2, default=str))

        print("=== OLLAMA_CHAT TRANSFORMED MESSAGES ===")
        print(json.dumps(new_messages, ensure_ascii=False, indent=2, default=str))

    return new_messages


def heal_tool_calls(
    tool_calls: Optional[List[dict]],
) -> Optional[List[dict]]:
    """
    Ollama が生成した Tool Call を
    Kanban/Cline の run_commands schema に近づける。
    """

    if not tool_calls:
        return tool_calls

    if DEBUG:
        print("=== RAW TOOL CALL ===")
        print(json.dumps(tool_calls, ensure_ascii=False, indent=2, default=str,))

    for tool_call in tool_calls:
        function = tool_call.get("function") or {}

        if function.get("name") != "run_commands":
            continue

        arguments = function.get("arguments")

        # JSON文字列で来た場合にも対応
        if isinstance(arguments, str):
            try:
                arguments = json.loads(arguments)
                function["arguments"] = arguments
            except Exception:
                continue

        if not isinstance(arguments, dict):
            continue

        commands = arguments.get("commands")

        # --------------------------------------------------
        # commands 自体が JSON文字列になっているケースを補正
        # --------------------------------------------------
        commands = _parse_commands_value(commands)

        if isinstance(commands, list):
            arguments["commands"] = commands
        else:
            if DEBUG:
                print("=== WARNING: INVALID COMMANDS VALUE ===")
                print(
                    json.dumps(
                        {
                            "value": commands,
                            "type": type(commands).__name__,
                        },
                        ensure_ascii=False,
                        indent=2,
                        default=str,
                    )
                )

            continue

        for item in commands:
            if not isinstance(item, dict):
                continue

            _heal_command_item(item)

    if DEBUG:
        print("=== HEALED TOOL CALL ===")
        print(json.dumps(tool_calls, ensure_ascii=False, indent=2, default=str,))

    return tool_calls


def _parse_commands_value(value):
    """
    run_commands.arguments.commands が
    JSON文字列として生成された場合に list へ戻す。

    例:
        '[{"command":"powershell","args":["-NoProfile"]}]'

    また、ローカルLLMが稀に生成する

        ,""Bypass"

    のような余分な引用符も限定的に補正する。
    """

    if isinstance(value, list):
        return value

    if not isinstance(value, str):
        return value

    raw = value.strip()

    if DEBUG:
        print("=== COMMANDS STRING DETECTED ===")
        print(raw)

    # --------------------------------------------------
    # 1. まず正しい JSON として解釈してみる
    # --------------------------------------------------
    try:
        parsed = json.loads(raw)

        if isinstance(parsed, list):
            if DEBUG:
                print("=== COMMANDS STRING PARSED ===")
                print(
                    json.dumps(
                        parsed,
                        ensure_ascii=False,
                        indent=2,
                        default=str,
                    )
                )

            return parsed

    except json.JSONDecodeError as e:
        if DEBUG:
            print("=== COMMANDS JSON PARSE FAILED ===")
            print(
                f"{e.msg} "
                f"(line={e.lineno}, column={e.colno}, pos={e.pos})"
            )

    # --------------------------------------------------
    # 2. 限定的な quote healing
    #
    # Bad:
    #   ,""Bypass"
    #
    # Good:
    #   ,"Bypass"
    #
    # 値の先頭が英数字、'-', '.', '/', '\' の場合だけ対象。
    # --------------------------------------------------
    repaired = re.sub(
        r'(?<=[,\[])""(?=[A-Za-z0-9_.\\/:-])',
        '"',
        raw,
    )

    if repaired == raw:
        # 修復対象が見つからなければ、無理に変更しない
        if DEBUG:
            print("=== COMMANDS STRING NOT HEALED ===")

        return value

    if DEBUG:
        print("=== COMMANDS STRING REPAIRED ===")
        print("BEFORE:")
        print(raw)
        print("AFTER:")
        print(repaired)

    # --------------------------------------------------
    # 3. 修復後にもう一度 JSON parse
    # --------------------------------------------------
    try:
        parsed = json.loads(repaired)

        if isinstance(parsed, list):
            if DEBUG:
                print("=== COMMANDS STRING HEAL SUCCESS ===")
                print(
                    json.dumps(
                        parsed,
                        ensure_ascii=False,
                        indent=2,
                        default=str,
                    )
                )

            return parsed

    except json.JSONDecodeError as e:
        if DEBUG:
            print("=== COMMANDS STRING HEAL FAILED ===")
            print(
                f"{e.msg} "
                f"(line={e.lineno}, column={e.colno}, pos={e.pos})"
            )

    # 修復できなければ元データを返す
    return value


def _heal_command_item(item: dict) -> None:
    command = item.get("command")

    if not isinstance(command, str):
        return

    command = command.strip()

    # --------------------------------------------------
    # Case 1:
    # "python ./foo.py --help"
    #       ↓
    # command="python"
    # args=["./foo.py", "--help"]
    # --------------------------------------------------

    args = item.get("args")

    if not isinstance(args, list) or len(args) == 0:
        _split_combined_command(item)

    # --------------------------------------------------
    # Case 2:
    # モデルが args2 という存在しないフィールドを
    # 作ってしまった場合
    # --------------------------------------------------

    if "args2" in item:
        _heal_args2(item)

    _debug_validate_command_item(item)


def _split_combined_command(item: dict) -> None:
    command_line = item.get("command", "")

    if not isinstance(command_line, str):
        return

    before = dict(item)

    known_commands = {
        "python",
        "python3",
        "py",
        "powershell",
        "powershell.exe",
        "pwsh",
        "pwsh.exe",
    }

    if " " not in command_line.strip():
        return

    try:
        parts = shlex.split(
            command_line,
            posix=False,
        )
    except Exception:
        return

    if len(parts) < 2:
        return

    executable = parts[0].strip('"')

    if executable.lower() not in known_commands:
        return

    args = []

    for arg in parts[1:]:
        if (
            len(arg) >= 2
            and arg[0] == arg[-1]
            and arg[0] in ('"', "'")
        ):
            arg = arg[1:-1]

        args.append(arg)

    item["command"] = executable
    item["args"] = args

    if DEBUG:
        print("=== HEAL: SPLIT_COMBINED_COMMAND ===")
        print(json.dumps({
            "before": before,
            "after": item,
        }, ensure_ascii=False, indent=2, default=str))


def _heal_args2(item: dict) -> None:
    """
    モデルが以下のように args / args2 を作ったケースを
    最低限正規化する。

    {
        "command": "powershell",
        "args": [...],
        "args2": [...]
    }
    """

    args = item.get("args")
    args2 = item.get("args2")

    if not isinstance(args2, list):
        return

    if not isinstance(args, list):
        args = []

    command = str(item.get("command", "")).lower()

    # PowerShellのみ対象に限定
    if command not in {
        "powershell",
        "powershell.exe",
        "pwsh",
        "pwsh.exe",
    }:
        return

    if DEBUG:
        print("=== HEAL CANDIDATE: ARGS2 ===")
        print(json.dumps(
            item,
            ensure_ascii=False,
            indent=2,
            default=str,
        ))

    # args2 は PowerShell 本体側の引数として
    # 生成される傾向があるため前側に置く
    item["args"] = args2 + args
    item.pop("args2", None)



def _debug_validate_command_item(item: dict) -> None:
    if not DEBUG:
        return

    allowed_keys = {
        "command",
        "args",
    }

    unknown_keys = set(item.keys()) - allowed_keys

    if unknown_keys:
        print("=== WARNING: UNKNOWN RUN_COMMANDS FIELDS ===")
        print(json.dumps({
            "unknown_keys": sorted(unknown_keys),
            "item": item,
        }, ensure_ascii=False, indent=2, default=str))

    command = item.get("command")
    args = item.get("args")

    if not isinstance(command, str):
        print("=== WARNING: INVALID COMMAND ===")
        print(json.dumps(item, ensure_ascii=False, indent=2, default=str))

    if args is not None:
        if (
            not isinstance(args, list)
            or not all(isinstance(x, str) for x in args)
        ):
            print("=== WARNING: INVALID ARGS ===")
            print(json.dumps(item, ensure_ascii=False, indent=2, default=str))

「transformation.py」ファイルは以下のように修正します。

$diff -u ./transformation.py.org ./transformation.py
--- ./transformation.py.org     2026-04-20 16:14:32.244684600 +0900
+++ ./transformation.py 2026-08-30 13:39:26.632287000 +0900
@@ -45,6 +45,10 @@
 else:
     LiteLLMLoggingObj = Any

+from litellm.llms.ollama.chat.cline_compat import (
+    patch_request_messages,
+    heal_tool_calls,
+)

 class OllamaChatConfig(BaseConfig):
     """
@@ -299,6 +303,14 @@

             new_messages.append(ollama_message)

+        # =========================================================
+        # LOCAL PATCH: Cline/Kanban compatibility
+        # =========================================================
+        new_messages = patch_request_messages(
+            messages,
+            new_messages,
+        )
+
         # Load Config
         config = self.get_config()
         for k, v in config.items():
@@ -489,6 +501,12 @@

             # process tool calls - if complete function arg - add id to tool call
             tool_calls = chunk["message"].get("tool_calls")
+
+            # =========================================================
+            # LOCAL PATCH: Heal malformed run_commands tool calls
+            # =========================================================
+            tool_calls = heal_tool_calls(tool_calls)
+
             if tool_calls is not None:
                 for tool_call in tool_calls:
                     function_args = tool_call.get("function").get("arguments")

こちらの表現の方がわかり易いかもしれません。

$ diff ./transformation.py.org ./transformation.py
47a48,51
> from litellm.llms.ollama.chat.cline_compat import (
>     patch_request_messages,
>     heal_tool_calls,
> )
301a306,313
>         # =========================================================
>         # LOCAL PATCH: Cline/Kanban compatibility
>         # =========================================================
>         new_messages = patch_request_messages(
>             messages,
>             new_messages,
>         )
>
491a504,509
>
>             # =========================================================
>             # LOCAL PATCH: Heal malformed run_commands tool calls
>             # =========================================================
>             tool_calls = heal_tool_calls(tool_calls)
>

以上で修正は終了です。

筆者の環境では、この補正を追加したことで Python / PowerShell コマンドの実行失敗が大幅に減り、確認した WORKER タスクでは再試行せずに処理を完了できました。

Kanban と Cline CLI の設定を分ける方法

これまでの検証で、Kanban と Cline CLI はデフォルトで設定ファイルを共有していることが分かりました。Cline CLI で「cline -P litellm -m "ollama-gpt-oss:20b"...」などと実行すると Kanban の設定も変わってしまうようです。

両方の機能を同時に使用したい場合に問題が起きる可能性があると思われますので、それぞれの設定を分離する方法をご紹介します。

ターミナル画面で「cline --help」と入力して、Cline のヘルプを確認すると「--config」と「--data-dir」という項目があり、Kanban とは独立して設定ファイルを持てるようです。

--config <path>   Configuration directory (default: ~/.cline)
--data-dir <path> Use isolated local state at this directory path (default: ~/.cline/data)

そこで新たに「.cline-litellm」というフォルダを作成して、設定に関連していると思われる「models.json」「providers.json」「globalState.json」あたりのファイルを .cline フォルダからコピーしておきます。

%USERPROFILE%/
└── .cline-litellm/
   └── data/
      ├── settings/
      │  ├── models.json
      │  └── providers.json
      └──  globalState.json

あとは Cline CLI 実行時に「--config」と「--data-dir」オプションを指定すれば、Kanban 設定とは独立した設定で実行できます。

cline `
  --config "$env:USERPROFILE/.cline-litellm" `
  --data-dir "$env:USERPROFILE/.cline-litellm/data" `
  -P litellm `
  -m "ollama-qwen3.6:35b-a3b" `
  -t 600 `
  "create test.md file."
まとめ

今回は LiteLLM を導入し、Kanban と Cline CLI からローカル LLM を利用できる環境を構築しました。LiteLLM を共通のプロキシとして使用することで、ローカル LLM とクラウド LLM をモデル名の変更だけで切り替えられる構成に近づけることができました。

また、Cline CLI を LiteLLM 経由で使用できるようになったことで、Cline が持つファイル編集・コード検索・コマンド実行などの機能を、LangGraph や PowerShell、Python などの外部処理から直接利用できるようになりました。

Cline CLI は外部プログラムから同期的に実行でき、終了コードや実行結果を直接取得できます。そのため、これまでのように Kanban のタスク状態を監視して処理完了を判定する方法よりも、ワークフローを単純化できる可能性があります。

また、ユーザーインターフェイスが必要ない処理では、Kanban を経由せず LangGraph から Cline CLI を直接呼び出す構成も考えられます。この場合、Kanban のタスク管理や監視ループを省略できるため、よりシンプルなエージェントループを構築できそうです。

今回の構成によって、使用するモデルを切り替えるだけでローカル LLM からクラウド LLM へ移行できる土台ができました。次回は、この仕組みを利用して、ローカル LLM で処理できなかった場合に、より高性能なモデルへ自動的に切り替えるエスカレーション処理を実装していきます。

今回は以上となります。