diff --git a/README.md b/README.md index 780dca3..2d027d2 100644 --- a/README.md +++ b/README.md @@ -1,80 +1,64 @@ -これまでの試行錯誤と、この「凄そうなツール」に詰め込んだ機能をすべて盛り込んだ `README.md` を作成しました。 +# oproxy.py - The Strategic AI Proxy for Local LLMs -このプロキシは、単なる通信の中継器ではなく、**「リモートサーバーの限界を可視化し、Clineを快適に動かすための管制塔」**としての役割を担っています。 +`oproxy.py` は、RTX 3060 (12GB) などのローカル環境で **Cline** や **Zed** といった AI Agent を極限まで効率よく運用するために設計された、高機能・対話型リバースプロキシです。 + +## 🛠 主要機能 (Features) + +### 1. ⚡ 魂のカラー・パルス (Visual Pulse) + +AIとの通信状況をコンソール上にリアルタイムで可視化します。 + +* **`^^^^` (Cyan)**: リクエスト送信中(サイズに応じて脈動)。 +* **`|` (Yellow)**: ターゲットへの到達。 +* **`vvvv` (Green)**: レスポンス(ストリーミング)受信中。 +* **`*` (Yellow)**: 通信の正常完了。 + +### 2. 🧠 戦略的モデル分析 (Strategic Analysis: `l` / `ll`) + +単なるモデル一覧ではありません。12GB VRAM という「戦場」を生き抜くための分析を行います。 + +* **カラー判定**: +* **GREEN**: VRAMに余裕を持って収まる安全圏。 +* **YELLOW**: 12GBの限界に近い。他のアプリを閉じないと溢れる可能性あり。 +* **RED**: 12GBを超過。システムRAM(CPU推論)に溢れ、速度が極端に低下する警告。 + + +* **詳細表示 (`ll`)**: パラメータサイズ、量子化ビット数(Q4, Q3等)を瞬時に把握。 + +### 3. 📉 VRAM リアルタイム監視 (VRAM Status: `s`) + +Ollama の内部 API (`/api/ps`) を叩き、現在 VRAM にどのモデルがロードされ、何 GB 専有しているかを色付きで表示します。 + +### 4. 🔍 インテリジェント・データダンプ (Smart Dump: `d` / `dd`) + +AI Agent (Cline 等) が「なぜか思い通りに動かない」時の強力なデバッグツールです。 + +* **`d`**: **次の1リクエストだけ**、その中身(JSON)を美しく整形してぶちまけます。 +* **`dd`**: ダンプ予約のキャンセル。 +* **自動オフ**: 1回ダンプすると自動的にOFFに戻るため、ログが汚れるのを防ぎます。 + +### 5. 🏗 動的ターゲット・スイッチ (Dynamic Switching) + +プロキシを再起動することなく、ポート番号(例: `11432`, `11435`)を直接打ち込むだけで、背後の Ollama インスタンスを瞬時に切り替えます。 --- -## README.md +## ⌨️ コマンドリファレンス (Commands) -# Ollama Debugging Proxy (oproxy) - -リモートサーバー上の Ollama とローカルの Cline を繋ぎ、通信状態やサーバーリソース、モデルの適合性をリアルタイムに可視化するための高機能プロキシツールです。 - -### 🌟 主な機能 - -* **戦力分析 (Startup Scan)**: 起動時にリモートの全モデルをスキャンし、以下の2点から「Clineで動くか」を自動判定します。 -* **メモリ判定**: サーバーの空きメモリ(16.8 GiB基準)に収まるか。 -* **ツール判定**: Clineの操作に必要な `Tools (Function Calling)` に対応しているか。 - - -* **通信の可視化 (Real-time Logs)**: -* `^`: リクエスト送信(Cline → Proxy → Ollama) -* `v`: レスポンス受信(Ollama → Proxy → Cline) -* `|` / `*`: パケットの区切りと完了を視認。 -* **タイムスタンプ**: ミリ秒単位のログで、遅延が発生している箇所を特定。 - - -* **エラー翻訳**: Ollamaが返す不親切な JSON エラーを解析し、Clineのチャット画面上に「何が原因で、どう対策すべきか」の Markdown レポートとして流し込みます。 -* **物理的表示の安定性**: 絵文字の幅によるズレを排除した「背景色付きバッジ」システムにより、ターミナル上での完璧な整列を実現。 +| コマンド | 機能 | +| --- | --- | +| **`l`** | モデルリストを表示(サイズに応じた色分け分析付き) | +| **`ll`** | 量子化ビット数を含む、詳細なモデル分析を表示 | +| **`s`** | 現在の VRAM 使用状況(どのモデルが専有中か)を表示 | +| **`d`** | **[DUMP ON]** 次のリクエスト・レスポンスを完全表示 | +| **`dd`** | **[DUMP OFF]** ダンプ予約の解除 | +| **`数字`** | ターゲットとする Ollama のポート番号を即時変更 | +| **`q`** | プロキシの安全な終了 | --- -### 🚀 使い方 +## 🚀 導入のメリット -#### 1. 起動 - -```bash -python oproxy.py -r 11433 -l 11434 - -``` - -* `-r`: リモートの Ollama ポート(SSHトンネル等のポート) -* `-l`: Cline が接続するローカルポート - -#### 2. リストの確認 - -起動時に表示されるリストで **`READY`** と出ているモデルを確認してください。 - -* `READY` (緑): メモリ・機能ともに合格。 -* `TOOL` (黄): メモリは足りるが、ファイル操作等ができない可能性あり。 -* `MEM` (赤): メモリ不足でロードに失敗します。 - -#### 3. Cline の設定 - -Cline の設定画面で `Base URL` を `http://127.0.0.1:11434` に設定し、リストで確認した最適なモデル名を入力してください。 - ---- - -### 🛠 必要要件 - -* **Python**: 3.12以上推奨 -* **依存ライブラリ**: -* `fastapi` -* `uvicorn` -* `httpx` - - - ---- - -### 🎨 ログ表示の見方 - -```text -[HH:MM:SS.ms] READY [TOOL] モデル名 (サイズ) GiB -[HH:MM:SS.ms] /api/chat: ^^^^|v:vvvvvvvvv* - -``` - -* `^` が出たまま止まる場合:アップロード(テザリング等の上り)がボトルネック。 -* `|` の後に `v` がなかなか出ない場合:サーバー側の推論待ち、またはメモリのロード中。 -* `v` が少しずつ出る場合:ストリーミング中。 +1. **Agent対応の判断**: `d` でダンプを見れば、モデルが正しく「ツール呼び出し(JSON)」を行っているか一目瞭然です。 +2. **30bモデルの運用**: RTX 3060 で 30b クラスを動かす際、`s` と `l` の分析により「あ、これはQ3量子化じゃないと無理だ」といった戦略的な判断が可能になります。 +3. **長考への耐性**: 600秒のタイムアウト設定により、巨大モデルの深い思考(Reasoning)を途中で遮断しません。 diff --git a/oproxy.py b/oproxy.py old mode 100755 new mode 100644 index e4e525b..7de8656 --- a/oproxy.py +++ b/oproxy.py @@ -1,11 +1,13 @@ # バージョン情報: Python 3.12+ / FastAPI 0.115.0 / uvicorn 0.30.0 / httpx 0.28.0 +# [2026-02-10] 3060(12GB)戦術支援:全機能完全統合・非短縮版 import argparse import asyncio import json import os +import re +import signal import sys import threading -import unicodedata from contextlib import asynccontextmanager from datetime import datetime @@ -14,6 +16,7 @@ import uvicorn from fastapi import FastAPI, Request from starlette.responses import StreamingResponse +# --- 視認性向上のためのカラー定数 --- C_GRAY, C_CYAN, C_GREEN, C_YELLOW, C_RED, C_WHITE, C_RESET = ( "\033[90m", "\033[96m", @@ -25,217 +28,276 @@ C_GRAY, C_CYAN, C_GREEN, C_YELLOW, C_RED, C_WHITE, C_RESET = ( ) CONFIG = { - "remote_port": 11430, - "url": "http://127.0.0.1:11430", - "timeout": httpx.Timeout(None), - "loop": None, + "remote_port": 11432, + "url": "http://127.0.0.1:11432", + "timeout": httpx.Timeout(600.0, connect=10.0), + "dump_next": False, "models_cache": [], + "loop": None, + "vram_total": 12.0, } +# 高速抽出用正規表現 +RE_CONTENT = re.compile(r'"(?:content|response)":\s*"(.*?)(?7}{C_RESET} | {C_WHITE}{gb:>6.2f}GB{C_RESET}" + ) + # 2行目: モデル本体名 + 量子化詳細 (detail_mode時) + q_info = f" {C_GRAY}[{meta['q']}]{C_RESET}" if detail_mode else "" + print(f" {'':<6} | {color}{model_base:<45}{C_RESET} | {q_info}") + + print(f"{C_CYAN}{'=' * 88}{C_RESET}") + + +# --- 📊 VRAM リアルタイムステータス --- +def show_vram_status(): + async def _fetch(): + try: + async with httpx.AsyncClient(timeout=2.0) as client: + res = await client.get(f"{CONFIG['url']}/api/ps") + if res.status_code == 200: + models = res.json().get("models", []) + print(f"\n{get_ts()} {C_CYAN}--- Active VRAM Usage ---{C_RESET}") + if not models: + print( + f"{get_ts()} {C_GRAY}No models currently in VRAM.{C_RESET}" + ) + for m in models: + v_gb, t_gb = ( + m.get("size_vram", 0) / (1024**3), + m.get("size", 0) / (1024**3), + ) + v_color = ( + C_RED if v_gb > 11 else (C_YELLOW if v_gb > 8 else C_GREEN) + ) + print( + f"{get_ts()} {C_WHITE}{m['name']:<25}{C_RESET} {v_color}{v_gb:.2f}{C_RESET} / {t_gb:.2f} GB" + ) + except: + print(f"\n{C_RED}[Error] Could not fetch VRAM status.{C_RESET}") + + if CONFIG["loop"]: + asyncio.run_coroutine_threadsafe(_fetch(), CONFIG["loop"]) + + +# --- 🚀 Proxy Core (高速リアルタイムDUMP実装) --- @asynccontextmanager async def lifespan(app: FastAPI): CONFIG["loop"] = asyncio.get_running_loop() - asyncio.create_task(update_model_cache()) + asyncio.create_task(fetch_detailed_models()) yield app = FastAPI(lifespan=lifespan) -# --- ロジック:モデルリスト取得 --- -async def update_model_cache(): - try: - async with httpx.AsyncClient(timeout=10.0) as client: - res = await client.get(f"{CONFIG['url']}/api/tags") - if res.status_code == 200: - new_data = [] - for m in res.json().get("models", []): - # ツールサポートの簡易判定 - has_tool = False - try: - s = await client.post( - f"{CONFIG['url']}/api/show", json={"name": m["name"]} - ) - info = s.json() - details = str(info.get("template", "")) + str( - info.get("details", "") - ) - has_tool = any( - w in details.lower() for w in ["tool", "functions"] - ) - except: - pass - new_data.append( - { - "name": m["name"], - "size": m["size"] / (1024**3), - "tool": has_tool, - } - ) - CONFIG["models_cache"] = new_data - except: - pass - - -def show_help(): - print( - f"\n{get_ts()} {C_WHITE}>>> h:HELP l:LIST ll:DETAIL s:VRAM [digit]:PORT q:EXIT <<<{C_RESET}", - flush=True, - ) - - -def display_models(full=False, short=False): - if not CONFIG["models_cache"] or short: - print( - f"\n{get_ts()} {C_YELLOW}Cache is empty. Ollama may be offline.{C_RESET}", - flush=True, - ) - return - print( - f"\n{get_ts()} {C_GREEN}--- Models ({'Detailed' if full else 'Short'}) ---{C_RESET}", - flush=True, - ) - NAME_W = 55 - for m in CONFIG["models_cache"]: - icon = "❌" if m["size"] > 16.8 else ("✅" if m["tool"] else "⚠️") - tag = f"{C_CYAN}[T]{C_RESET}" if m["tool"] else f"{C_GRAY}[-]{C_RESET}" - if full: - print(f"{get_ts()} {icon} {tag} {C_WHITE}{m['name']}{C_RESET}") - print(f"{get_ts()} {C_GRAY}└─ {m['size']:>6.1f} GiB{C_RESET}") - else: - n = m["name"] - if get_width(n) > NAME_W: - while get_width("..." + n) > NAME_W: - n = n[1:] - n = "..." + n - print( - f"{get_ts()} {icon} {tag} {C_WHITE}{pad_text(n, NAME_W)}{C_RESET} {C_CYAN}{m['size']:>6.1f} GiB{C_RESET}" - ) - print(f"{get_ts()} {C_GREEN}--- End ---{C_RESET}\n", flush=True) - - -# --- Proxy 本体 --- @app.api_route("/{path:path}", methods=["GET", "POST", "PUT", "DELETE"]) async def sticky_proxy(path: str, request: Request): - print(f"\n{get_ts()} {C_WHITE}/{path}{C_RESET} ", end="", flush=True) + do_dump = CONFIG["dump_next"] + if do_dump: + CONFIG["dump_next"] = False + + print(f"{get_ts()} {C_WHITE}/{path: <10}{C_RESET} ", end="", flush=True) body = await request.body() - # リクエストの長さに応じてインジケータを出す - for _ in range(min(len(body) // 256 + 1, 5)): + + if do_dump: + print( + f"\n{C_YELLOW}{'=' * 60}\n[DUMP REQUEST: {request.method} /{path}]\n{'=' * 60}{C_RESET}" + ) + try: + print(json.dumps(json.loads(body), indent=2, ensure_ascii=False)) + except: + print(f"{C_GRAY}Body (Raw): {body[:500]!r}{C_RESET}") + print(f"\n{C_GREEN}[REALTIME AI RESPONSE CONTENT]{C_RESET}") + + # 送信パルス + for _ in range(max(1, min(len(body) // 512, 12))): pulse("^", C_CYAN) pulse("|", C_YELLOW) async def stream_response(): - # 接続エラーを回避するためにリクエストごとにClientを生成 - async with httpx.AsyncClient( - timeout=CONFIG["timeout"], base_url="http://127.0.0.1:11432" - ) as client: + async with httpx.AsyncClient(timeout=CONFIG["timeout"]) as client: try: - # 宛先を強制的に 127.0.0.1 に固定したURLで構築 - target_url = f"{CONFIG['url']}/{path}" + headers = { + k: v + for k, v in request.headers.items() + if k.lower() not in ["host", "content-length", "connection"] + } async with client.stream( request.method, - target_url, + f"{CONFIG['url']}/{path}", content=body, - headers={ - k: v - for k, v in request.headers.items() - if k.lower() not in ["host", "content-length"] - }, + headers=headers, ) as response: pulse("v", C_GREEN) + async for chunk in response.aiter_bytes(): + if do_dump: + # チャンクから正規表現で高速テキスト抽出(リアルタイム表示) + raw_data = chunk.decode(errors="ignore") + matches = RE_CONTENT.findall(raw_data) + for m in matches: + try: + text = m.encode().decode( + "unicode_escape", errors="ignore" + ) + print( + f"{C_WHITE}{text}{C_RESET}", end="", flush=True + ) + except: + pass + pulse("v", C_GREEN) yield chunk + + if do_dump: + print(f"\n{C_GREEN}{'=' * 60}{C_RESET}") pulse("*", C_YELLOW) except Exception as e: - print(f" {C_RED}[Err] {type(e).__name__}: {e}{C_RESET}", flush=True) + print(f" {C_RED}[Proxy Error] {e}{C_RESET}") finally: print("", flush=True) return StreamingResponse(stream_response()) -def input_thread(): +# --- ⌨️ コマンド入力ハンドラ --- +def input_handler(): + print( + f"\n{C_GREEN}oproxy: Full Tactical Suite (RTX 3060 12GB Edition) Active{C_RESET}" + ) + print( + f"{C_GRAY}Commands: [d]Dump [s]VRAM [l]List [ll]Detail [digit]Port [q]Exit{C_RESET}\n" + ) while True: try: - line = sys.stdin.readline() + line = sys.stdin.readline().strip().lower() if not line: - break - cmd = line.strip().lower() - if cmd == "q": - os._exit(0) - elif cmd == "h": - show_help() - elif cmd == "l": - display_models(False) - elif cmd == "ll": - display_models(full=True) - elif cmd == "s": - - async def ps(): - async with httpx.AsyncClient() as c: - r = await c.get(f"{CONFIG['url']}/api/ps") - if r.status_code == 200: - print(f"\n{get_ts()} {C_CYAN}--- VRAM ---{C_RESET}") - for m in r.json().get("models", []): - print( - f"{get_ts()} {m['name']:<25} {m['size_vram'] / (1024**3):.1f}G" - ) - - if CONFIG["loop"]: - asyncio.run_coroutine_threadsafe(ps(), CONFIG["loop"]) - elif cmd.isdigit(): - p = int(cmd) - CONFIG["remote_port"], CONFIG["url"] = p, f"http://127.0.0.1:{p}" + continue + if line == "q": + os.kill(os.getpid(), signal.SIGINT) + elif line in ["d", "dd"]: + CONFIG["dump_next"] = not CONFIG["dump_next"] print( - f"\n{get_ts()} {C_YELLOW}Switch Target -> {CONFIG['url']}{C_RESET}" + f"{get_ts()} DUMP Reservation: {'ON' if CONFIG['dump_next'] else 'OFF'}" ) - if CONFIG["loop"]: - asyncio.run_coroutine_threadsafe( - update_model_cache(), CONFIG["loop"] - ) + elif line == "s": + show_vram_status() + elif line in ["l", "ll"]: + asyncio.run_coroutine_threadsafe( + fetch_detailed_models(), CONFIG["loop"] + ) + print_analysis(detail_mode=(line == "ll")) + elif line.isdigit(): + p = int(line) + CONFIG["remote_port"], CONFIG["url"] = p, f"http://127.0.0.1:{p}" + print(f"{get_ts()} Target Port Switched -> {p}") except: - break + pass if __name__ == "__main__": parser = argparse.ArgumentParser() - parser.add_argument( - "-r", "--remote", type=int, default=11432 - ) # デフォルトを11432に + parser.add_argument("-r", "--remote", type=int, default=11432) parser.add_argument("-l", "--local", type=int, default=11434) args = parser.parse_args() - - CONFIG["remote_port"] = args.remote - CONFIG["url"] = f"http://127.0.0.1:{args.remote}" - - threading.Thread(target=input_thread, daemon=True).start() - print( - f"\n{get_ts()} {C_CYAN}oproxy Start (L:{args.local} -> R:{args.remote}){C_RESET}" + CONFIG.update( + {"remote_port": args.remote, "url": f"http://127.0.0.1:{args.remote}"} ) - show_help() + + # メインスレッドでUvicorn、サブスレッドで入力待ち + threading.Thread(target=input_handler, daemon=True).start() uvicorn.run(app, host="127.0.0.1", port=args.local, log_level="error")