From 481c8af07b191da521060b1ce1e453277ba1036a Mon Sep 17 00:00:00 2001 From: user Date: Sun, 8 Feb 2026 01:37:41 +0900 Subject: [PATCH] =?UTF-8?q?=E3=82=B9=E3=82=AD=E3=83=A3=E3=83=B3=E5=8D=B3?= =?UTF-8?q?=E6=99=82=E5=AE=9F=E8=A1=8C=20=EF=BC=86=20=E3=82=B3=E3=83=9E?= =?UTF-8?q?=E3=83=B3=E3=83=89=E5=AE=89=E5=AE=9A=E7=89=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- oproxy.py | 250 +++++++++++++++++++++++++++++++++++++++--------------- 1 file changed, 180 insertions(+), 70 deletions(-) diff --git a/oproxy.py b/oproxy.py index e059af0..016f5b7 100755 --- a/oproxy.py +++ b/oproxy.py @@ -1,128 +1,238 @@ # バージョン情報: Python 3.12+ / FastAPI 0.115.0 / uvicorn 0.30.0 / httpx 0.28.0 -import httpx, asyncio, json, sys, threading, os, argparse, re, unicodedata +import argparse +import asyncio +import json +import os +import re +import sys +import threading +import unicodedata from datetime import datetime + +import httpx import uvicorn from fastapi import FastAPI, Request from starlette.responses import StreamingResponse app = FastAPI() -C_GRAY, C_CYAN, C_GREEN, C_YELLOW, C_RED, C_RESET = "\033[90m", "\033[96m", "\033[92m", "\033[93m", "\033[91m", "\033[0m" -DEFAULT_REMOTE_PORT, DEFAULT_LOCAL_PORT = 11430, 11434 +# --- カラー・設定 --- +C_GRAY, C_CYAN, C_GREEN, C_YELLOW, C_RED, C_RESET = ( + "\033[90m", + "\033[96m", + "\033[92m", + "\033[93m", + "\033[91m", + "\033[0m", +) +B_GREEN, B_YELLOW, B_RED = "\033[42;30m", "\033[43;30m", "\033[41;37m" + MEM_LIMIT = 16.8 -CONFIG = {"url": f"http://127.0.0.1:{DEFAULT_REMOTE_PORT}", "timeout": httpx.Timeout(None)} +NAME_MAX_WIDTH = 50 +CONFIG = {"url": "http://127.0.0.1:11430"} + def get_ts(): return f"{C_GRAY}[{datetime.now().strftime('%H:%M:%S.%f')[:-3]}]{C_RESET}" -def clean_text(text): - """特殊な空白文字(\xa0等)を通常の空白に置換し、前後のゴミを取る""" - return "".join(c for c in text if unicodedata.category(c) != 'Cf').replace('\xa0', ' ').strip() def get_visual_width(text): - """文字の表示幅を計算 (全角2, 半角1)""" - width = 0 - for c in text: - if unicodedata.east_asian_width(c) in ('W', 'F', 'A'): - width += 2 - else: - width += 1 - return width + return sum( + 2 if unicodedata.east_asian_width(c) in ("W", "F", "A") else 1 for c in text + ) + def pad_right(text, width): - """表示幅に基づいて正確に右パディングする""" - t = clean_text(text) - curr_w = get_visual_width(t) - return t + ' ' * max(0, width - curr_w) + plain_text = re.sub(r"\033\[[0-9;]*m", "", text) + return text + " " * max(0, width - get_visual_width(plain_text)) + + +def draw_progress(current, total, model_name=""): + width = 30 + filled = int(width * current / total) + bar = "█" * filled + "░" * (width - filled) + percent = (current / total) * 100 + sys.stdout.write( + f"\r{get_ts()} {C_CYAN}[Scanning] |{bar}| {percent:>3.0f}% {C_GRAY}({model_name[:20]}...){C_RESET}" + ) + sys.stdout.flush() -# --- 以下、ロジック部分は維持しつつ表示部のみ微調整 --- async def check_tool_support(client, model_name): try: res = await client.post(f"{CONFIG['url']}/api/show", json={"name": model_name}) if res.status_code == 200: info = res.json() - # template, system, modelfile 等から tool 関連のキーワードを執念深く探す - look_in = [info.get("template", ""), info.get("system", ""), info.get("modelfile", "")] - content = " ".join(look_in).lower() + content = " ".join( + [ + info.get("template", ""), + info.get("system", ""), + info.get("modelfile", ""), + ] + ).lower() return any(x in content for x in ["tool", "function", "call", "assistant"]) - except: pass + except: + pass return False -async def check_connection(): + +def run_analyze(): + asyncio.run(analyze_models()) + + +async def analyze_models(): url = CONFIG["url"] - print(f"{get_ts()} {C_YELLOW}[Check] {url} 分析中...{C_RESET}") + print(f"\n{get_ts()} {C_YELLOW}[Analyze] {url} 接続開始...{C_RESET}") try: async with httpx.AsyncClient(timeout=10.0) as client: res = await client.get(f"{url}/api/tags") - if res.status_code == 200: - models = res.json().get('models', []) - header_text = f"--- リモートモデル戦力分析 (基準: {MEM_LIMIT}GiB + Tool対応) ---" - print(f"{get_ts()} {C_GREEN}{header_text}{C_RESET}") + if res.status_code != 200: + print(f"{get_ts()} {C_RED}分析エラー: HTTP {res.status_code}{C_RESET}") + return - for m in models: - name = m['name'] - size_gb = m['size'] / (1024**3) - has_tool = await check_tool_support(client, name) + models_data = res.json().get("models", []) + total = len(models_data) + enriched = [] - if size_gb > MEM_LIMIT: color, status = C_RED, "❌ MEM " - elif not has_tool: color, status = C_YELLOW, "⚠️ TOOL" - else: color, status = C_GREEN, "✅ READY" + for i, m in enumerate(models_data, 1): + full_name = m["name"] + draw_progress(i, total, full_name.split("/")[-1]) + size_gb = m["size"] / (1024**3) + has_tool = await check_tool_support(client, full_name) + score = ( + 0 + if size_gb <= MEM_LIMIT and has_tool + else (1 if size_gb <= MEM_LIMIT else 2) + ) + enriched.append( + { + "full_name": full_name, + "display_name": full_name.split("/")[-1], + "size_gb": size_gb, + "has_tool": has_tool, + "score": score, + } + ) - tool_mark = f"{C_CYAN}[TOOL]{C_RESET}" if has_tool else f"{C_GRAY}[----]{C_RESET}" + print("\n") + enriched.sort(key=lambda x: (x["score"], x["display_name"], -x["size_gb"])) - # カラム幅を微調整 (名前: 70) - display_name = pad_right(name, 70) - print(f"{get_ts()} {color}{status:<8}{C_RESET} {tool_mark} {display_name} {size_gb:>5.1f} GiB") + print( + f"{get_ts()} {C_GREEN}--- リモートモデル戦力分析 (Target: {url}) ---{C_RESET}" + ) + prefix_width = 32 + for em in enriched: + status = ( + f"{B_GREEN} READY{C_RESET}" + if em["score"] == 0 + else ( + f"{B_YELLOW} TOOL {C_RESET}" + if em["score"] == 1 + else f"{B_RED} MEM {C_RESET}" + ) + ) + tool = ( + f"{C_CYAN}[TOOL]{C_RESET}" + if em["has_tool"] + else f"{C_GRAY}[----]{C_RESET}" + ) + name, size = em["display_name"], f"{em['size_gb']:>5.1f} GiB" - print(f"{get_ts()} {C_GREEN}{'-' * 100}{C_RESET}\n") - return True + if get_visual_width(name) > NAME_MAX_WIDTH: + print(f"{get_ts()} {status} {tool} {name[:NAME_MAX_WIDTH]} {size}") + print( + f"{get_ts()} {' ' * (prefix_width - 15)} {C_GRAY}└ {name[NAME_MAX_WIDTH:]}{C_RESET}" + ) + else: + print( + f"{get_ts()} {status} {tool} {pad_right(name, NAME_MAX_WIDTH)} {size}" + ) + + print(f"{get_ts()} {C_GREEN}{'-' * 80}{C_RESET}") + show_help() except Exception as e: - print(f"{get_ts()} {C_RED}!! 接続失敗 !!: {e}{C_RESET}") - return False + print(f"\n{get_ts()} {C_RED}分析失敗: {e}{C_RESET}") + + +def show_help(): + print(f"\n{C_CYAN}[Command Help]{C_RESET}") + print(f" {C_YELLOW}:p [port]{C_RESET} - 転送先(Ollama)のポートを切り替えて再分析") + print(f" {C_YELLOW}?{C_RESET} - このヘルプを表示") + print(f" {C_YELLOW}q{C_RESET} - プロキシを終了") + print(f"{C_GRAY}------------------------------------------{C_RESET}\n") -# --- API Route, main, wait_for_quit --- (省略せず統合してください) @app.api_route("/{path:path}", methods=["GET", "POST", "PUT", "DELETE"]) async def sticky_proxy(path: str, request: Request): - print(f"\n{get_ts()} /{path}: ", end="", flush=True) - body = b""; async for chunk in request.stream(): - body += chunk; print(f"{C_CYAN}^{C_RESET}", end="", flush=True) - print(f"{C_YELLOW}|{C_RESET}", end="", flush=True) + target_url = f"{CONFIG['url']}/{path}" + + # --- 修正箇所: SyntaxError を防ぐため完全に複数行に展開 --- + body = b"" + async for chunk in request.stream(): + body += chunk + # ------------------------------------------------------ + + headers = { + k: v + for k, v in request.headers.items() + if k.lower() not in ["host", "content-length"] + } - headers = {k: v for k, v in request.headers.items() if k.lower() not in ["host", "content-length"]} async def stream_response(): try: async with httpx.AsyncClient(timeout=None) as client: - async with client.stream(request.method, f"{CONFIG['url']}/{path}", content=body, headers=headers) as response: - if response.status_code != 200: - err_data = await response.aread(); err_msg = err_data.decode(errors='ignore') - print(f" {C_RED}{err_msg}{C_RESET}") - yield json.dumps({"message": {"role": "assistant", "content": f"### Error\n{err_msg}"}, "done": True}).encode() - return - print(f"{C_GREEN}v:{C_RESET}", end="", flush=True) + async with client.stream( + request.method, target_url, content=body, headers=headers + ) as response: async for chunk in response.aiter_bytes(): - print(f"{C_GREEN}v{C_RESET}", end="", flush=True) yield chunk - print(f"{C_YELLOW}*{C_RESET}", end="", flush=True) - except Exception as e: print(f" {C_RED}[Err] {e}{C_RESET}") + except Exception as e: + print(f" {C_RED}[Err] {e}{C_RESET}") return StreamingResponse(stream_response()) -def wait_for_quit(): + +def interactive_shell(): while True: - line = sys.stdin.readline() - if line and line.strip().lower() == 'q': os._exit(0) + try: + line = sys.stdin.readline().strip().lower() + if not line: + continue + + if line == "q": + os._exit(0) + elif line == "?": + show_help() + elif line.startswith(":p"): + parts = line.split() + if len(parts) > 1: + new_port = parts[1] + CONFIG["url"] = f"http://127.0.0.1:{new_port}" + threading.Thread(target=run_analyze, daemon=True).start() + else: + print( + f"{C_RED}ポート番号を指定してください (例: :p 11435){C_RESET}" + ) + else: + print( + f"{C_GRAY}未知のコマンドです: '{line}' ( ? でヘルプ表示 ){C_RESET}" + ) + except EOFError: + break + def main(): parser = argparse.ArgumentParser() - parser.add_argument("-r", "--remote", type=int, default=DEFAULT_REMOTE_PORT) - parser.add_argument("-l", "--local", type=int, default=DEFAULT_LOCAL_PORT) + parser.add_argument("-r", "--remote", type=int, default=11430) + parser.add_argument("-l", "--local", type=int, default=11434) args = parser.parse_args() CONFIG["url"] = f"http://127.0.0.1:{args.remote}" - loop = asyncio.new_event_loop(); asyncio.set_event_loop(loop) - loop.run_until_complete(check_connection()) - threading.Thread(target=wait_for_quit, daemon=True).start() + + asyncio.run(analyze_models()) + + threading.Thread(target=interactive_shell, daemon=True).start() uvicorn.run(app, host="127.0.0.1", port=args.local, log_level="error") -if __name__ == "__main__": main() + +if __name__ == "__main__": + main()