From 2a090c9704bee62edf46dff4f138c824ea81b4a3 Mon Sep 17 00:00:00 2001 From: thug0bin Date: Fri, 27 Feb 2026 12:22:05 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20Clawdbot=20Gateway=20=EB=AA=A8=EB=8B=88?= =?UTF-8?q?=ED=84=B0=EB=A7=81=20=ED=8E=98=EC=9D=B4=EC=A7=80=20+=20API=20?= =?UTF-8?q?=ED=81=B4=EB=9D=BC=EC=9D=B4=EC=96=B8=ED=8A=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - /admin/ai-gw: 토큰 사용량/비용 실시간 모니터링 대시보드 - clawdbot_client.py: Gateway HTTP API 클라이언트 (세션 상태, 사용량 조회) - 세션별 토큰/비용 통계, 모델별 breakdown - API 문서 추가 (docs/clawdbot-gateway-api.md) --- backend/services/clawdbot_client.py | 116 ++++++ backend/templates/admin_ai_gw.html | 559 ++++++++++++++++++++++++++++ docs/clawdbot-gateway-api.md | 342 +++++++++++++++++ 3 files changed, 1017 insertions(+) create mode 100644 backend/templates/admin_ai_gw.html create mode 100644 docs/clawdbot-gateway-api.md diff --git a/backend/services/clawdbot_client.py b/backend/services/clawdbot_client.py index 3031026..5b56883 100644 --- a/backend/services/clawdbot_client.py +++ b/backend/services/clawdbot_client.py @@ -321,6 +321,122 @@ def generate_upsell_real(user_name, current_items, recent_products, available_pr return _parse_upsell_response(response_text) +# ===== Claude 상태 조회 ===== + +async def _get_gateway_status(): + """ + Clawdbot Gateway에서 세션 목록 조회 + 토큰 차감 없음 (AI 호출 아님) + """ + config = _load_gateway_config() + url = f"ws://127.0.0.1:{config['port']}" + token = config['token'] + + try: + async with websockets.connect(url, max_size=25 * 1024 * 1024, + close_timeout=5) as ws: + # 1. connect.challenge 대기 + challenge_msg = await asyncio.wait_for(ws.recv(), timeout=10) + challenge = json.loads(challenge_msg) + nonce = None + if challenge.get('event') == 'connect.challenge': + nonce = challenge.get('payload', {}).get('nonce') + + # 2. connect 요청 + connect_id = str(uuid.uuid4()) + connect_frame = { + 'type': 'req', + 'id': connect_id, + 'method': 'connect', + 'params': { + 'minProtocol': 3, + 'maxProtocol': 3, + 'client': { + 'id': 'gateway-client', + 'displayName': 'Pharmacy Status', + 'version': '1.0.0', + 'platform': 'win32', + 'mode': 'backend', + 'instanceId': str(uuid.uuid4()), + }, + 'caps': [], + 'auth': {'token': token}, + 'role': 'operator', + 'scopes': ['operator.read'], + } + } + await ws.send(json.dumps(connect_frame)) + + # 3. connect 응답 대기 + while True: + msg = await asyncio.wait_for(ws.recv(), timeout=10) + data = json.loads(msg) + if data.get('id') == connect_id: + if not data.get('ok'): + error = data.get('error', {}).get('message', 'connect failed') + logger.warning(f"[Clawdbot] connect 실패: {error}") + return {'error': error, 'connected': False} + break + + # 4. sessions.list 요청 + list_id = str(uuid.uuid4()) + list_frame = { + 'type': 'req', + 'id': list_id, + 'method': 'sessions.list', + 'params': { + 'limit': 10 + } + } + await ws.send(json.dumps(list_frame)) + + # 5. 응답 대기 + while True: + msg = await asyncio.wait_for(ws.recv(), timeout=10) + data = json.loads(msg) + + # 이벤트 무시 + if data.get('event'): + continue + + if data.get('id') == list_id: + if data.get('ok'): + return { + 'connected': True, + 'sessions': data.get('payload', {}) + } + else: + error = data.get('error', {}).get('message', 'unknown') + return {'error': error, 'connected': True} + + except asyncio.TimeoutError: + logger.warning("[Clawdbot] Gateway 타임아웃") + return {'error': 'timeout', 'connected': False} + except (ConnectionRefusedError, OSError) as e: + logger.warning(f"[Clawdbot] Gateway 연결 실패: {e}") + return {'error': str(e), 'connected': False} + except Exception as e: + logger.warning(f"[Clawdbot] 상태 조회 실패: {e}") + return {'error': str(e), 'connected': False} + + +def get_claude_status(): + """ + 동기 래퍼: Claude 상태 조회 + + Returns: + dict: 상태 정보 + """ + try: + loop = asyncio.new_event_loop() + result = loop.run_until_complete(_get_gateway_status()) + loop.close() + return result + except Exception as e: + logger.warning(f"[Clawdbot] 상태 조회 실패: {e}") + return {'error': str(e), 'connected': False} + + def _parse_upsell_response(text): """AI 응답에서 JSON 추출""" import re diff --git a/backend/templates/admin_ai_gw.html b/backend/templates/admin_ai_gw.html new file mode 100644 index 0000000..21cd2ae --- /dev/null +++ b/backend/templates/admin_ai_gw.html @@ -0,0 +1,559 @@ + + + + + + AI Gateway 모니터 - 청춘약국 + + + + + + +
+ +

+ + AI Gateway 모니터 +

+

Clawdbot Gateway 실시간 상태 · Claude / GPT 토큰 사용량

+
+ +
+
+ +
+
+
+
현재 모델
+
로딩중...
+
+ +
+ +
+
+ -- + / 200k + 0% +
+
+
+
+
+ +
+
+
입력 토큰
+
-
+
+
+
출력 토큰
+
-
+
+
+
전체 토큰 (모든 세션)
+
-
+
+
+
활성 세션
+
-
+
+
+
+ + +
+ + +
+
+
세션별 상세
+
-
+
+
+
+ +
-
+
+
+ + + + diff --git a/docs/clawdbot-gateway-api.md b/docs/clawdbot-gateway-api.md new file mode 100644 index 0000000..eb3a786 --- /dev/null +++ b/docs/clawdbot-gateway-api.md @@ -0,0 +1,342 @@ +# Clawdbot Gateway WebSocket API 가이드 + +> 외부 애플리케이션에서 Clawdbot Gateway에 연결하여 AI 호출 또는 상태 조회하는 방법 + +## 개요 + +Clawdbot Gateway는 WebSocket API를 제공합니다. 이를 통해: +- **AI 호출** (`agent` 메서드) — Claude/GPT 등 모델에 질문 (토큰 소비) +- **상태 조회** (`sessions.list` 등) — 세션 정보 조회 (토큰 무소비) +- **세션 설정** (`sessions.patch`) — 모델 오버라이드 등 + +## 아키텍처 + +``` +┌─────────────────┐ WebSocket ┌─────────────────┐ +│ Flask 서버 │ ◄─────────────────► │ Clawdbot Gateway│ +│ (pharmacy-pos) │ Port 18789 │ (localhost) │ +└─────────────────┘ └────────┬────────┘ + │ + ┌────────▼────────┐ + │ Claude / GPT │ + │ (Providers) │ + └─────────────────┘ +``` + +## 설정 파일 위치 + +Gateway 설정은 `~/.clawdbot/clawdbot.json`에 있음: +```json +{ + "gateway": { + "port": 18789, + "auth": { + "mode": "token", + "token": "your-gateway-token" + } + } +} +``` + +--- + +## 연결 프로토콜 (Python) + +### 1. 기본 연결 흐름 + +```python +import asyncio +import json +import uuid +import websockets + +async def connect_to_gateway(): + config = load_gateway_config() # ~/.clawdbot/clawdbot.json 읽기 + url = f"ws://127.0.0.1:{config['port']}" + token = config['token'] + + async with websockets.connect(url) as ws: + # 1단계: challenge 수신 + challenge = json.loads(await ws.recv()) + # {'event': 'connect.challenge', 'payload': {'nonce': '...'}} + + # 2단계: connect 요청 + connect_frame = { + 'type': 'req', + 'id': str(uuid.uuid4()), + 'method': 'connect', + 'params': { + 'minProtocol': 3, + 'maxProtocol': 3, + 'client': { + 'id': 'gateway-client', # 고정값 + 'displayName': 'My App', + 'version': '1.0.0', + 'platform': 'win32', + 'mode': 'backend', # 고정값 + 'instanceId': str(uuid.uuid4()), + }, + 'caps': [], + 'auth': {'token': token}, + 'role': 'operator', + 'scopes': ['operator.admin'], # 또는 ['operator.read'] + } + } + await ws.send(json.dumps(connect_frame)) + + # 3단계: connect 응답 대기 + while True: + msg = json.loads(await ws.recv()) + if msg.get('id') == connect_frame['id']: + if msg.get('ok'): + print("연결 성공!") + break + else: + print(f"연결 실패: {msg.get('error')}") + return + + # 이제 다른 메서드 호출 가능 + # ... +``` + +### 2. 주의사항: client 파라미터 + +⚠️ **중요**: `client.id`와 `client.mode`는 Gateway 스키마에 정의된 값만 허용됨 + +| 필드 | 허용되는 값 | 설명 | +|------|-------------|------| +| `client.id` | `'gateway-client'` | 백엔드 클라이언트용 | +| `client.mode` | `'backend'` | 백엔드 모드 | +| `role` | `'operator'` | 제어 클라이언트 | +| `scopes` | `['operator.admin']` 또는 `['operator.read']` | 권한 범위 | + +잘못된 값 사용 시 에러: +``` +invalid connect params: at /client/id: must be equal to constant +``` + +--- + +## 메서드 종류 + +### 토큰 소비 없는 메서드 (관리용) + +| 메서드 | 용도 | 파라미터 | +|--------|------|----------| +| `sessions.list` | 세션 목록 조회 | `{limit: 10}` | +| `sessions.patch` | 세션 설정 변경 | `{key: '...', model: '...'}` | + +### 토큰 소비하는 메서드 (AI 호출) + +| 메서드 | 용도 | 파라미터 | +|--------|------|----------| +| `agent` | AI에게 질문 | `{message: '...', sessionId: '...'}` | + +--- + +## 실제 구현 예제 + +### 예제 1: 상태 조회 (토큰 0) + +```python +# services/clawdbot_client.py 참고 + +async def _get_gateway_status(): + """세션 목록 조회 — 토큰 소비 없음""" + # ... (연결 코드 생략) + + # sessions.list 요청 + list_frame = { + 'type': 'req', + 'id': str(uuid.uuid4()), + 'method': 'sessions.list', + 'params': {'limit': 10} + } + await ws.send(json.dumps(list_frame)) + + # 응답 대기 + while True: + msg = json.loads(await ws.recv()) + if msg.get('event'): # 이벤트는 무시 + continue + if msg.get('id') == list_frame['id']: + return msg.get('payload', {}) +``` + +**응답 예시:** +```json +{ + "sessions": [ + { + "key": "agent:main:main", + "totalTokens": 30072, + "contextTokens": 200000, + "model": "claude-opus-4-5" + } + ], + "defaults": { + "model": "claude-opus-4-5", + "contextTokens": 200000 + } +} +``` + +### 예제 2: AI 호출 (토큰 소비) + +```python +async def ask_ai(message, session_id='my-session', model=None): + """AI에게 질문 — 토큰 소비함""" + # ... (연결 코드) + + # 모델 오버라이드 (선택) + if model: + patch_frame = { + 'type': 'req', + 'id': str(uuid.uuid4()), + 'method': 'sessions.patch', + 'params': {'key': session_id, 'model': model} + } + await ws.send(json.dumps(patch_frame)) + # 응답 대기... + + # agent 요청 + agent_frame = { + 'type': 'req', + 'id': str(uuid.uuid4()), + 'method': 'agent', + 'params': { + 'message': message, + 'sessionId': session_id, + 'sessionKey': session_id, + 'timeout': 60, + } + } + await ws.send(json.dumps(agent_frame)) + + # 응답 대기 (accepted → final) + while True: + msg = json.loads(await ws.recv()) + if msg.get('event'): + continue + if msg.get('id') == agent_frame['id']: + if msg.get('payload', {}).get('status') == 'accepted': + continue # 아직 처리 중 + # 최종 응답 + payloads = msg.get('payload', {}).get('result', {}).get('payloads', []) + return '\n'.join(p.get('text', '') for p in payloads) +``` + +### 예제 3: 모델 오버라이드 + +비싼 Opus 대신 저렴한 Sonnet 사용: + +```python +UPSELL_MODEL = 'anthropic/claude-sonnet-4-5' + +response = await ask_ai( + message="추천 멘트 만들어줘", + session_id='upsell-customer1', + model=UPSELL_MODEL # Sonnet으로 오버라이드 +) +``` + +--- + +## Flask API 엔드포인트 예제 + +```python +# app.py + +@app.route('/api/claude-status') +def api_claude_status(): + """토큰 차감 없이 상태 조회""" + from services.clawdbot_client import get_claude_status + + status = get_claude_status() + + if not status.get('connected'): + return jsonify({'ok': False, 'error': status.get('error')}), 503 + + sessions = status.get('sessions', {}) + # ... 데이터 가공 + + return jsonify({ + 'ok': True, + 'context': {'used': 30000, 'max': 200000, 'percent': 15}, + 'model': 'claude-opus-4-5' + }) +``` + +--- + +## 토큰 관리 전략 + +### 모델별 용도 분리 + +| 용도 | 모델 | 이유 | +|------|------|------| +| 메인 컨트롤러 | Claude Opus | 복잡한 추론, 도구 사용 | +| 단순 생성 (업셀링 등) | Claude Sonnet | 빠르고 저렴 | +| 코딩 작업 | GPT-5 Codex | 정식 지원, 안정적 | + +### 세션 분리 + +```python +# 용도별 세션 ID 분리 +ask_ai("...", session_id='upsell-고객명') # 업셀링 전용 +ask_ai("...", session_id='analysis-daily') # 분석 전용 +ask_ai("...", session_id='chat-main') # 일반 대화 +``` + +--- + +## 트러블슈팅 + +### 1. "invalid connect params" 에러 + +``` +at /client/id: must be equal to constant +at /client/mode: must be equal to constant +``` + +**해결**: `client.id`는 `'gateway-client'`, `client.mode`는 `'backend'` 사용 + +### 2. Gateway 연결 실패 + +```python +ConnectionRefusedError: [WinError 10061] +``` + +**해결**: Clawdbot Gateway가 실행 중인지 확인 +```bash +clawdbot gateway status +``` + +### 3. CLI 명령어가 hang됨 + +Clawdbot 내부(agent 세션)에서 `clawdbot status` 같은 CLI 호출하면 충돌. +→ WebSocket API 직접 사용할 것 + +--- + +## 파일 위치 + +``` +pharmacy-pos-qr-system/ +└── backend/ + └── services/ + └── clawdbot_client.py # Gateway 클라이언트 구현 + └── app.py # Flask API (/api/claude-status) +``` + +--- + +## 참고 자료 + +- Clawdbot 문서: `C:\Users\청춘약국\AppData\Roaming\npm\node_modules\clawdbot\docs\` +- Gateway 프로토콜: `docs/gateway/protocol.md` +- 설정 예제: `docs/gateway/configuration-examples.md` + +--- + +*작성: 2026-02-27 | 용림 🐉*