色々拡充した

This commit is contained in:
user 2026-02-10 08:59:22 +09:00
parent f2c2039d45
commit efb48dc746
2 changed files with 274 additions and 228 deletions

126
README.md
View file

@ -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を超過。システムRAMCPU推論に溢れ、速度が極端に低下する警告。
* **詳細表示 (`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 を繋ぎ、通信状態やサーバーリソース、モデルの適合性をリアルタイムに可視化するための高機能プロキシツールです。 | **`l`** | モデルリストを表示(サイズに応じた色分け分析付き) |
| **`ll`** | 量子化ビット数を含む、詳細なモデル分析を表示 |
### 🌟 主な機能 | **`s`** | 現在の VRAM 使用状況(どのモデルが専有中か)を表示 |
| **`d`** | **[DUMP ON]** 次のリクエスト・レスポンスを完全表示 |
* **戦力分析 (Startup Scan)**: 起動時にリモートの全モデルをスキャンし、以下の2点から「Clineで動くか」を自動判定します。 | **`dd`** | **[DUMP OFF]** ダンプ予約の解除 |
* **メモリ判定**: サーバーの空きメモリ16.8 GiB基準に収まるか。 | **`数字`** | ターゲットとする Ollama のポート番号を即時変更 |
* **ツール判定**: Clineの操作に必要な `Tools (Function Calling)` に対応しているか。 | **`q`** | プロキシの安全な終了 |
* **通信の可視化 (Real-time Logs)**:
* `^`: リクエスト送信Cline → Proxy → Ollama
* `v`: レスポンス受信Ollama → Proxy → Cline
* `|` / `*`: パケットの区切りと完了を視認。
* **タイムスタンプ**: ミリ秒単位のログで、遅延が発生している箇所を特定。
* **エラー翻訳**: Ollamaが返す不親切な JSON エラーを解析し、Clineのチャット画面上に「何が原因で、どう対策すべきか」の Markdown レポートとして流し込みます。
* **物理的表示の安定性**: 絵文字の幅によるズレを排除した「背景色付きバッジ」システムにより、ターミナル上での完璧な整列を実現。
--- ---
### 🚀 使い方 ## 🚀 導入のメリット
#### 1. 起動 1. **Agent対応の判断**: `d` でダンプを見れば、モデルが正しく「ツール呼び出しJSON」を行っているか一目瞭然です。
2. **30bモデルの運用**: RTX 3060 で 30b クラスを動かす際、`s` と `l` の分析により「あ、これはQ3量子化じゃないと無理だ」といった戦略的な判断が可能になります。
```bash 3. **長考への耐性**: 600秒のタイムアウト設定により、巨大モデルの深い思考Reasoningを途中で遮断しません。
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` が少しずつ出る場合:ストリーミング中。

376
oproxy.py Executable file → Normal file
View file

@ -1,11 +1,13 @@
# バージョン情報: Python 3.12+ / FastAPI 0.115.0 / uvicorn 0.30.0 / httpx 0.28.0 # バージョン情報: Python 3.12+ / FastAPI 0.115.0 / uvicorn 0.30.0 / httpx 0.28.0
# [2026-02-10] 3060(12GB)戦術支援:全機能完全統合・非短縮版
import argparse import argparse
import asyncio import asyncio
import json import json
import os import os
import re
import signal
import sys import sys
import threading import threading
import unicodedata
from contextlib import asynccontextmanager from contextlib import asynccontextmanager
from datetime import datetime from datetime import datetime
@ -14,6 +16,7 @@ import uvicorn
from fastapi import FastAPI, Request from fastapi import FastAPI, Request
from starlette.responses import StreamingResponse from starlette.responses import StreamingResponse
# --- 視認性向上のためのカラー定数 ---
C_GRAY, C_CYAN, C_GREEN, C_YELLOW, C_RED, C_WHITE, C_RESET = ( C_GRAY, C_CYAN, C_GREEN, C_YELLOW, C_RED, C_WHITE, C_RESET = (
"\033[90m", "\033[90m",
"\033[96m", "\033[96m",
@ -25,217 +28,276 @@ C_GRAY, C_CYAN, C_GREEN, C_YELLOW, C_RED, C_WHITE, C_RESET = (
) )
CONFIG = { CONFIG = {
"remote_port": 11430, "remote_port": 11432,
"url": "http://127.0.0.1:11430", "url": "http://127.0.0.1:11432",
"timeout": httpx.Timeout(None), "timeout": httpx.Timeout(600.0, connect=10.0),
"loop": None, "dump_next": False,
"models_cache": [], "models_cache": [],
"loop": None,
"vram_total": 12.0,
} }
# 高速抽出用正規表現
RE_CONTENT = re.compile(r'"(?:content|response)":\s*"(.*?)(?<!\\)"')
def get_ts(): def get_ts():
ts = datetime.now().strftime("%H:%M:%S.%f")[:-3] return f"{C_GRAY}[{datetime.now().strftime('%H:%M:%S')}]{C_RESET}"
return f"{C_GRAY}[{ts}] [:{CONFIG['remote_port']}]{C_RESET}"
def get_width(text):
count = 0
for c in text:
if unicodedata.east_asian_width(c) in "FWA":
count += 2
else:
count += 1
return count
def pad_text(text, target_width):
return text + (" " * max(0, target_width - get_width(text)))
def pulse(char, color=C_RESET): def pulse(char, color=C_RESET):
print(f"{color}{char}{C_RESET}", end="", flush=True) print(f"{color}{char}{C_RESET}", end="", flush=True)
# --- 🧠 戦略的モデル分析 (2行分割レイアウト) ---
async def fetch_detailed_models():
"""Ollamaの内部情報を深掘りし、メタデータを完全取得する"""
try:
async with httpx.AsyncClient(timeout=5.0) as client:
res = await client.get(f"{CONFIG['url']}/api/tags")
if res.status_code != 200:
return
models = res.json().get("models", [])
for m in models:
try:
show_res = await client.post(
f"{CONFIG['url']}/api/show", json={"name": m["name"]}
)
if show_res.status_code == 200:
data = show_res.json()
templ = data.get("template", "").lower()
params = data.get("parameters", "")
# エージェント/補完タイプの判定
m_type = "inst"
if any(x in templ for x in ["tool", "parameters", "call"]):
m_type = "agent"
elif any(
x in m["name"].lower()
for x in ["acp", "fim", "base", "code"]
):
m_type = "acp"
# コンテキスト長の抽出
ctx = "Def"
if "num_ctx" in params:
ctx = params.split("num_ctx")[-1].strip().split()[0]
m["meta"] = {
"type": m_type,
"ctx": ctx,
"q": m.get("details", {}).get("quantization_level", "?"),
}
except:
m["meta"] = {"type": "inst", "ctx": "???", "q": "?"}
CONFIG["models_cache"] = models
except:
pass
def print_analysis(detail_mode=False):
"""ガタつきを排した重厚な2行リスト表示"""
print(
f"\n{C_CYAN}--- Strategic Agent Analysis (Target: {CONFIG['vram_total']}GB VRAM) ---{C_RESET}"
)
print(
f"{C_GRAY}{'STATUS':<7} | {'MODEL IDENTIFIER':<45} | {'TYPE':<7} | {'CTX':<7} | {'SIZE'}{C_RESET}"
)
print(f"{C_GRAY}{'-' * 88}{C_RESET}")
for m in CONFIG["models_cache"]:
gb = m.get("size", 0) / (1024**3)
meta = m.get("meta", {"type": "inst", "ctx": "???", "q": "?"})
# 3060(12GB)用 VRAM判定
if gb <= CONFIG["vram_total"] * 0.75:
color, status = C_GREEN, "SAFE"
elif gb <= CONFIG["vram_total"]:
color, status = C_YELLOW, "LIMIT"
else:
color, status = C_RED, "OVER"
type_c = (
C_CYAN
if meta["type"] == "agent"
else (C_YELLOW if meta["type"] == "acp" else C_WHITE)
)
full_name = m["name"]
if "/" in full_name:
parts = full_name.rsplit("/", 1)
org_path, model_base = parts[0] + "/", parts[1]
else:
org_path, model_base = "", full_name
# 1行目: ステータス、組織パス、タイプ、CTX、サイズ
print(
f" {color}{status:<6}{C_RESET} | {C_GRAY}{org_path:<45}{C_RESET} | {type_c}{meta['type']:<7}{C_RESET} | {C_WHITE}{str(meta['ctx']):>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 @asynccontextmanager
async def lifespan(app: FastAPI): async def lifespan(app: FastAPI):
CONFIG["loop"] = asyncio.get_running_loop() CONFIG["loop"] = asyncio.get_running_loop()
asyncio.create_task(update_model_cache()) asyncio.create_task(fetch_detailed_models())
yield yield
app = FastAPI(lifespan=lifespan) 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"]) @app.api_route("/{path:path}", methods=["GET", "POST", "PUT", "DELETE"])
async def sticky_proxy(path: str, request: Request): 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() 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_CYAN)
pulse("|", C_YELLOW) pulse("|", C_YELLOW)
async def stream_response(): async def stream_response():
# 接続エラーを回避するためにリクエストごとにClientを生成 async with httpx.AsyncClient(timeout=CONFIG["timeout"]) as client:
async with httpx.AsyncClient(
timeout=CONFIG["timeout"], base_url="http://127.0.0.1:11432"
) as client:
try: try:
# 宛先を強制的に 127.0.0.1 に固定したURLで構築 headers = {
target_url = f"{CONFIG['url']}/{path}" k: v
for k, v in request.headers.items()
if k.lower() not in ["host", "content-length", "connection"]
}
async with client.stream( async with client.stream(
request.method, request.method,
target_url, f"{CONFIG['url']}/{path}",
content=body, content=body,
headers={ headers=headers,
k: v
for k, v in request.headers.items()
if k.lower() not in ["host", "content-length"]
},
) as response: ) as response:
pulse("v", C_GREEN) pulse("v", C_GREEN)
async for chunk in response.aiter_bytes(): 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) pulse("v", C_GREEN)
yield chunk yield chunk
if do_dump:
print(f"\n{C_GREEN}{'=' * 60}{C_RESET}")
pulse("*", C_YELLOW) pulse("*", C_YELLOW)
except Exception as e: 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: finally:
print("", flush=True) print("", flush=True)
return StreamingResponse(stream_response()) 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: while True:
try: try:
line = sys.stdin.readline() line = sys.stdin.readline().strip().lower()
if not line: if not line:
break continue
cmd = line.strip().lower() if line == "q":
if cmd == "q": os.kill(os.getpid(), signal.SIGINT)
os._exit(0) elif line in ["d", "dd"]:
elif cmd == "h": CONFIG["dump_next"] = not CONFIG["dump_next"]
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}"
print( 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"]: elif line == "s":
asyncio.run_coroutine_threadsafe( show_vram_status()
update_model_cache(), CONFIG["loop"] 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: except:
break pass
if __name__ == "__main__": if __name__ == "__main__":
parser = argparse.ArgumentParser() parser = argparse.ArgumentParser()
parser.add_argument( parser.add_argument("-r", "--remote", type=int, default=11432)
"-r", "--remote", type=int, default=11432
) # デフォルトを11432に
parser.add_argument("-l", "--local", type=int, default=11434) parser.add_argument("-l", "--local", type=int, default=11434)
args = parser.parse_args() args = parser.parse_args()
CONFIG.update(
CONFIG["remote_port"] = args.remote {"remote_port": args.remote, "url": f"http://127.0.0.1:{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}"
) )
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") uvicorn.run(app, host="127.0.0.1", port=args.local, log_level="error")