feat(agent-client): Claude 및 Pi 에이전트 클라이언트를 위한 설치 스크립트와 README를 추가한다

This commit is contained in:
toki 2026-07-10 15:35:26 +09:00
parent ddcc4f42f8
commit c58fbac0d5
5 changed files with 1104 additions and 0 deletions

View file

@ -0,0 +1,30 @@
# Claude Agent Client
기존 Seulgivibe Claude 설정이 되어 있는 macOS 환경에서 Claude TUI `/model`에 아래 모델을 함께 노출한다.
```text
claude-seulgivibe-sonnet-4-5
claude-seulgivibe-opus-4-8
claude-seulgivibe-mythos-5
claude-dev-corp-gemma4-26b
```
## 설치
```bash
cd agent-client/claude
./install.sh
```
설치 후 Claude TUI를 새로 열고 `/model`에서 모델을 선택한다.
## 전제
- `~/.claude/anthropic_key.sh`가 이미 설정되어 있어야 한다.
- dev-corp 토큰은 있으면 아래 순서로 사용한다.
- `~/.claude/dev-corp-openai-key.sh`
- `~/.claude/dev-corp-openai-api-key`
- `~/.pi/agent/dev-corp-openai-api-key`
- 없으면 `~/.claude/anthropic_key.sh` 출력값을 fallback으로 사용한다.
JWT/API key 원문은 이 폴더에 포함하지 않는다.

183
agent-client/claude/install.sh Executable file
View file

@ -0,0 +1,183 @@
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
CLAUDE_DIR="$HOME/.claude"
SETTINGS="$CLAUDE_DIR/settings.json"
GATEWAY="$CLAUDE_DIR/iop-claude-gateway.py"
DEV_CORP_KEY_HELPER="$CLAUDE_DIR/dev-corp-openai-key.sh"
LABEL="com.iop.agent-client.claude"
PLIST="$HOME/Library/LaunchAgents/$LABEL.plist"
PYTHON_BIN="/usr/bin/python3"
UID_VALUE="$(id -u)"
if [[ ! -x "$PYTHON_BIN" ]]; then
PYTHON_BIN="$(command -v python3 || true)"
fi
if [[ -z "$PYTHON_BIN" || ! -x "$PYTHON_BIN" ]]; then
echo "python3 를 찾을 수 없습니다."
exit 1
fi
if [[ ! -x "$CLAUDE_DIR/anthropic_key.sh" ]]; then
echo "~/.claude/anthropic_key.sh 설정이 먼저 필요합니다."
exit 1
fi
mkdir -p "$CLAUDE_DIR/cache" "$HOME/Library/LaunchAgents"
OLD_LABELS=(
"$LABEL"
"com.seulgivibe.claude.models"
"com.seulgivibe.claude.anthropic-proxy"
"com.leedongmyung.claude.seulgivibe-anthropic-proxy"
)
for old_label in "${OLD_LABELS[@]}"; do
launchctl bootout "gui/$UID_VALUE/$old_label" >/dev/null 2>&1 || true
done
OLD_PLISTS=(
"$PLIST"
"$HOME/Library/LaunchAgents/com.seulgivibe.claude.models.plist"
"$HOME/Library/LaunchAgents/com.seulgivibe.claude.anthropic-proxy.plist"
"$HOME/Library/LaunchAgents/com.leedongmyung.claude.seulgivibe-anthropic-proxy.plist"
)
for old_plist in "${OLD_PLISTS[@]}"; do
rm -f "$old_plist"
done
for old_process in \
"$CLAUDE_DIR/iop-claude-gateway.py" \
"$CLAUDE_DIR/seulgivibe-claude-gateway.py" \
"$CLAUDE_DIR/dev-corp-claude-gateway.py" \
"$CLAUDE_DIR/seulgivibe-anthropic-proxy.mjs"
do
pids="$(pgrep -f "$old_process" || true)"
if [[ -n "$pids" ]]; then
while IFS= read -r pid; do
[[ -n "$pid" && "$pid" != "$$" ]] && kill "$pid" >/dev/null 2>&1 || true
done <<< "$pids"
fi
done
rm -f "$CLAUDE_DIR/seulgivibe-anthropic-proxy.mjs"
install -m 755 "$SCRIPT_DIR/iop-claude-gateway.py" "$GATEWAY"
if [[ ! -x "$DEV_CORP_KEY_HELPER" ]]; then
cat > "$DEV_CORP_KEY_HELPER" <<'EOF'
#!/bin/sh
set -eu
if [ -f "$HOME/.claude/dev-corp-openai-api-key" ]; then
tr -d '\n' < "$HOME/.claude/dev-corp-openai-api-key"
exit 0
fi
if [ -f "$HOME/.pi/agent/dev-corp-openai-api-key" ]; then
tr -d '\n' < "$HOME/.pi/agent/dev-corp-openai-api-key"
exit 0
fi
exec "$HOME/.claude/anthropic_key.sh"
EOF
chmod 755 "$DEV_CORP_KEY_HELPER"
fi
"$PYTHON_BIN" - "$SETTINGS" "$CLAUDE_DIR/cache/gateway-models.json" <<'PY'
import json
import pathlib
import sys
import time
settings_path = pathlib.Path(sys.argv[1])
cache_path = pathlib.Path(sys.argv[2])
settings_path.parent.mkdir(parents=True, exist_ok=True)
cache_path.parent.mkdir(parents=True, exist_ok=True)
settings = {}
if settings_path.exists():
backup = settings_path.with_name(f"{settings_path.name}.bak-{time.strftime('%Y%m%d%H%M%S')}")
backup.write_bytes(settings_path.read_bytes())
settings = json.loads(settings_path.read_text() or "{}")
models = [
"claude-seulgivibe-sonnet-4-5",
"claude-seulgivibe-opus-4-8",
"claude-seulgivibe-mythos-5",
"claude-dev-corp-gemma4-26b",
]
settings["apiKeyHelper"] = settings.get("apiKeyHelper") or "~/.claude/anthropic_key.sh"
env = settings.setdefault("env", {})
env["ANTHROPIC_BASE_URL"] = "http://127.0.0.1:18443/anthropic"
env["NODE_TLS_REJECT_UNAUTHORIZED"] = "0"
env["CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY"] = "1"
for key in [
"ANTHROPIC_CUSTOM_MODEL_OPTION",
"ANTHROPIC_CUSTOM_MODEL_OPTION_NAME",
"ANTHROPIC_CUSTOM_MODEL_OPTION_DESCRIPTION",
"ANTHROPIC_DEFAULT_FABLE_MODEL",
"ANTHROPIC_DEFAULT_FABLE_MODEL_NAME",
"ANTHROPIC_DEFAULT_FABLE_MODEL_DESCRIPTION",
]:
env.pop(key, None)
if settings.get("model") not in models:
settings["model"] = "claude-seulgivibe-mythos-5"
settings["availableModels"] = models
settings["enforceAvailableModels"] = True
settings.setdefault("effortLevel", "xhigh")
settings.setdefault("alwaysThinkingEnabled", True)
settings_path.write_text(json.dumps(settings, indent=2, ensure_ascii=False) + "\n")
cache = {
"baseUrl": "http://127.0.0.1:18443/anthropic",
"fetchedAt": int(time.time() * 1000),
"models": [
{"id": "claude-seulgivibe-sonnet-4-5", "display_name": "Seulgivibe Sonnet 4.5"},
{"id": "claude-seulgivibe-opus-4-8", "display_name": "Seulgivibe Opus 4.8"},
{"id": "claude-seulgivibe-mythos-5", "display_name": "Seulgivibe Fable 5"},
{"id": "claude-dev-corp-gemma4-26b", "display_name": "dev-corp gemma4:26b"},
],
}
cache_path.write_text(json.dumps(cache, ensure_ascii=False, separators=(",", ":")) + "\n")
PY
cat > "$PLIST" <<EOF
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key>
<string>$LABEL</string>
<key>ProgramArguments</key>
<array>
<string>$PYTHON_BIN</string>
<string>$GATEWAY</string>
</array>
<key>RunAtLoad</key>
<true/>
<key>KeepAlive</key>
<true/>
<key>StandardOutPath</key>
<string>$CLAUDE_DIR/iop-claude-gateway.out.log</string>
<key>StandardErrorPath</key>
<string>$CLAUDE_DIR/iop-claude-gateway.err.log</string>
</dict>
</plist>
EOF
launchctl bootstrap "gui/$UID_VALUE" "$PLIST"
launchctl kickstart -k "gui/$UID_VALUE/$LABEL"
for _ in 1 2 3 4 5; do
curl -fsS "http://127.0.0.1:18443/health" >/dev/null 2>&1 && break
sleep 1
done
curl -fsS "http://127.0.0.1:18443/anthropic/v1/models" >/dev/null
echo "완료: Claude TUI를 새로 열고 /model 에서 모델을 선택하세요."

View file

@ -0,0 +1,635 @@
#!/usr/bin/env python3
import http.client
import json
import os
import ssl
import subprocess
import time
import uuid
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from urllib.parse import urlsplit
HOST = "127.0.0.1"
PORT = int(os.environ.get("IOP_CLAUDE_GATEWAY_PORT", "18443"))
SEULGIVIBE_HOST = os.environ.get("SEULGIVIBE_HOST", "seulgivibe.lgudax.cool")
SEULGIVIBE_PREFIX = os.environ.get("SEULGIVIBE_ANTHROPIC_PREFIX", "/anthropic")
SEULGIVIBE_KEY_HELPER = os.path.expanduser("~/.claude/anthropic_key.sh")
DEV_CORP_OPENAI_BASE_URL = os.environ.get(
"DEV_CORP_OPENAI_BASE_URL",
"http://digitalplatform-iop.cloud:18086/v1",
).rstrip("/")
DEV_CORP_KEY_HELPER = os.path.expanduser("~/.claude/dev-corp-openai-key.sh")
DEV_CORP_TOKEN_FILES = [
os.path.expanduser("~/.claude/dev-corp-openai-api-key"),
os.path.expanduser("~/.pi/agent/dev-corp-openai-api-key"),
]
DEV_CORP_MODEL = "claude-dev-corp-gemma4-26b"
DEV_CORP_UPSTREAM_MODEL = "gemma4:26b"
DEV_CORP_MIN_MAX_TOKENS = int(os.environ.get("DEV_CORP_UPSTREAM_MIN_MAX_TOKENS", "1024"))
SEULGIVIBE_ALIASES = {
"claude-seulgivibe-sonnet-4-5": "claude-sonnet-4-5-20250929",
"claude-seulgivibe-opus-4-8": "claude-opus-4-8",
"claude-seulgivibe-mythos-5": "claude-fable-5",
}
MODELS = [
{
"id": "claude-seulgivibe-sonnet-4-5",
"type": "model",
"display_name": "Seulgivibe Sonnet 4.5",
"description": "Seulgivibe Sonnet 4.5",
"created_at": "2026-05-13T00:00:00Z",
},
{
"id": "claude-seulgivibe-opus-4-8",
"type": "model",
"display_name": "Seulgivibe Opus 4.8",
"description": "Seulgivibe Opus 4.8",
"created_at": "2026-05-13T00:00:00Z",
},
{
"id": "claude-seulgivibe-mythos-5",
"type": "model",
"display_name": "Seulgivibe Fable 5",
"description": "Fable 5 · Most capable for your hardest and longest-running tasks · $10/$50 per Mtok",
"created_at": "2026-05-13T00:00:00Z",
},
{
"id": DEV_CORP_MODEL,
"type": "model",
"display_name": "dev-corp gemma4:26b",
"description": "dev-corp gemma4:26b via OpenAI-compatible gateway",
"created_at": "2026-07-10T00:00:00Z",
},
]
def is_record(value):
return isinstance(value, dict)
def content_to_text(content):
if isinstance(content, str):
return content
if isinstance(content, list):
parts = []
for item in content:
if isinstance(item, str):
parts.append(item)
elif is_record(item):
item_type = item.get("type")
if item_type in ("text", "input_text", "output_text"):
text = item.get("text")
if isinstance(text, str):
parts.append(text)
elif item_type == "tool_result":
text = content_to_text(item.get("content"))
if text:
parts.append(text)
return "\n".join(parts)
return ""
def normalize_system(system_value):
if isinstance(system_value, str):
return system_value
if isinstance(system_value, list):
return content_to_text(system_value)
return ""
def normalize_system_messages(payload):
messages = payload.get("messages")
if not isinstance(messages, list):
return
system_parts = []
regular_messages = []
for message in messages:
if is_record(message) and message.get("role") == "system":
text = content_to_text(message.get("content"))
if text:
system_parts.append(text)
else:
regular_messages.append(message)
if not system_parts:
return
existing = normalize_system(payload.get("system"))
payload["system"] = "\n\n".join(part for part in [existing, *system_parts] if part)
payload["messages"] = regular_messages
def read_helper(path):
if not os.path.exists(path):
return ""
try:
result = subprocess.run(
[path],
check=True,
stdout=subprocess.PIPE,
stderr=subprocess.DEVNULL,
text=True,
timeout=10,
)
except Exception:
return ""
return result.stdout.strip()
def read_token_file(path):
try:
with open(path, "r", encoding="utf-8") as handle:
return handle.read().strip()
except Exception:
return ""
def inbound_token(headers):
for key in ("authorization", "x-api-key", "api-key"):
value = headers.get(key)
if not value:
continue
value = value.strip()
if value.lower().startswith("bearer "):
return value[7:].strip()
return value
return ""
def seulgivibe_token(headers):
return inbound_token(headers) or read_helper(SEULGIVIBE_KEY_HELPER)
def dev_corp_token(headers):
token = read_helper(DEV_CORP_KEY_HELPER)
if token:
return token
for path in DEV_CORP_TOKEN_FILES:
token = read_token_file(path)
if token:
return token
return inbound_token(headers) or read_helper(SEULGIVIBE_KEY_HELPER)
def seulgivibe_path(raw_path):
if raw_path.startswith(f"{SEULGIVIBE_PREFIX}/"):
return raw_path
if raw_path.startswith("/v1/"):
return f"{SEULGIVIBE_PREFIX}{raw_path}"
return raw_path
def call_seulgivibe(method, raw_path, payload, inbound_headers):
outbound_payload = dict(payload)
model = outbound_payload.get("model")
if model in SEULGIVIBE_ALIASES:
normalize_system_messages(outbound_payload)
outbound_payload["model"] = SEULGIVIBE_ALIASES[model]
body = json.dumps(outbound_payload, ensure_ascii=False, separators=(",", ":")).encode("utf-8")
headers = {
"content-type": "application/json",
"accept": inbound_headers.get("accept", "application/json, text/event-stream"),
"content-length": str(len(body)),
}
for key in ("anthropic-version", "anthropic-beta"):
if inbound_headers.get(key):
headers[key] = inbound_headers[key]
token = seulgivibe_token(inbound_headers)
if token:
headers["authorization"] = f"Bearer {token}"
context = ssl._create_unverified_context()
conn = http.client.HTTPSConnection(SEULGIVIBE_HOST, 443, context=context, timeout=300)
try:
conn.request(method, seulgivibe_path(raw_path), body=body, headers=headers)
response = conn.getresponse()
return response.status, dict(response.getheaders()), response.read()
finally:
conn.close()
def unwrap_json_string_body(body):
try:
value = json.loads(body.decode("utf-8"))
except Exception:
return body
if not isinstance(value, str):
return body
stripped = value.strip()
if not stripped.startswith(("{", "[")):
return body
return stripped.encode("utf-8")
def anthropic_messages_to_openai(payload):
messages = []
system_text = normalize_system(payload.get("system"))
if system_text:
messages.append({"role": "system", "content": system_text})
tool_names = {}
for message in payload.get("messages") or []:
if not is_record(message):
continue
role = message.get("role")
content = message.get("content")
if role == "assistant":
text_parts = []
tool_calls = []
blocks = content if isinstance(content, list) else [{"type": "text", "text": content_to_text(content)}]
for block in blocks:
if not is_record(block):
continue
block_type = block.get("type")
if block_type == "text":
text = block.get("text")
if isinstance(text, str) and text:
text_parts.append(text)
elif block_type == "tool_use":
tool_id = str(block.get("id") or f"call_{uuid.uuid4().hex[:16]}")
name = str(block.get("name") or "")
tool_names[tool_id] = name
tool_calls.append(
{
"id": tool_id,
"type": "function",
"function": {
"name": name,
"arguments": json.dumps(block.get("input") or {}, ensure_ascii=False),
},
}
)
openai_message = {"role": "assistant", "content": "\n".join(text_parts) or None}
if tool_calls:
openai_message["tool_calls"] = tool_calls
messages.append(openai_message)
continue
if role == "user":
text_parts = []
blocks = content if isinstance(content, list) else [{"type": "text", "text": content_to_text(content)}]
for block in blocks:
if is_record(block) and block.get("type") == "tool_result":
tool_call_id = str(block.get("tool_use_id") or "")
tool_message = {
"role": "tool",
"tool_call_id": tool_call_id,
"content": content_to_text(block.get("content")),
}
name = tool_names.get(tool_call_id)
if name:
tool_message["name"] = name
messages.append(tool_message)
else:
text = content_to_text([block])
if text:
text_parts.append(text)
if text_parts:
messages.append({"role": "user", "content": "\n".join(text_parts)})
continue
if role in ("system", "developer"):
text = content_to_text(content)
if text:
messages.append({"role": "system", "content": text})
return messages
def anthropic_tools_to_openai(payload):
tools = []
for tool in payload.get("tools") or []:
if not is_record(tool):
continue
name = tool.get("name")
if not isinstance(name, str) or not name:
continue
parameters = tool.get("input_schema")
if not is_record(parameters):
parameters = {"type": "object", "properties": {}}
tools.append(
{
"type": "function",
"function": {
"name": name,
"description": tool.get("description") or "",
"parameters": parameters,
},
}
)
return tools
def build_openai_payload(payload):
body = {
"model": DEV_CORP_UPSTREAM_MODEL,
"messages": anthropic_messages_to_openai(payload),
"temperature": payload.get("temperature", 0.2),
"top_p": payload.get("top_p", 0.95),
"stream": False,
}
max_tokens = payload.get("max_tokens")
if isinstance(max_tokens, int) and max_tokens > 0:
body["max_tokens"] = max(max_tokens, DEV_CORP_MIN_MAX_TOKENS)
else:
body["max_tokens"] = DEV_CORP_MIN_MAX_TOKENS
tools = anthropic_tools_to_openai(payload)
if tools:
body["tools"] = tools
return body
def parse_openai_url(path):
parsed = urlsplit(DEV_CORP_OPENAI_BASE_URL)
scheme = parsed.scheme or "http"
host = parsed.hostname or ""
port = parsed.port
prefix = parsed.path.rstrip("/")
return scheme, host, port, f"{prefix}{path}"
def call_dev_corp(openai_payload, inbound_headers):
scheme, host, port, path = parse_openai_url("/chat/completions")
body = json.dumps(openai_payload, ensure_ascii=False, separators=(",", ":")).encode("utf-8")
headers = {
"content-type": "application/json",
"accept": "application/json",
"content-length": str(len(body)),
}
token = dev_corp_token(inbound_headers)
if token:
headers["authorization"] = f"Bearer {token}"
if scheme == "https":
context = ssl._create_unverified_context()
conn = http.client.HTTPSConnection(host, port or 443, context=context, timeout=300)
else:
conn = http.client.HTTPConnection(host, port or 80, timeout=300)
try:
conn.request("POST", path, body=body, headers=headers)
response = conn.getresponse()
return response.status, dict(response.getheaders()), response.read()
finally:
conn.close()
def parse_tool_arguments(raw):
if not isinstance(raw, str) or raw == "":
return {}
try:
value = json.loads(raw)
except Exception:
return {"raw": raw}
return value if is_record(value) else {"value": value}
def openai_to_anthropic(openai_payload, requested_model):
choice = (openai_payload.get("choices") or [{}])[0]
message = choice.get("message") or {}
finish_reason = choice.get("finish_reason")
content = []
text = message.get("content")
if isinstance(text, str) and text:
content.append({"type": "text", "text": text})
tool_calls = message.get("tool_calls") or []
for tool_call in tool_calls:
if not is_record(tool_call):
continue
function = tool_call.get("function") or {}
content.append(
{
"type": "tool_use",
"id": str(tool_call.get("id") or f"call_{uuid.uuid4().hex[:16]}"),
"name": str(function.get("name") or ""),
"input": parse_tool_arguments(function.get("arguments")),
}
)
usage = openai_payload.get("usage") or {}
if tool_calls:
stop_reason = "tool_use"
elif finish_reason == "length":
stop_reason = "max_tokens"
else:
stop_reason = "end_turn"
return {
"id": openai_payload.get("id") or f"msg_{uuid.uuid4().hex}",
"type": "message",
"role": "assistant",
"model": requested_model or DEV_CORP_MODEL,
"content": content or [{"type": "text", "text": ""}],
"stop_reason": stop_reason,
"stop_sequence": None,
"usage": {
"input_tokens": int(usage.get("prompt_tokens") or 0),
"output_tokens": int(usage.get("completion_tokens") or 0),
},
}
def estimate_tokens(payload):
text = normalize_system(payload.get("system"))
for message in payload.get("messages") or []:
if is_record(message):
text += "\n" + content_to_text(message.get("content"))
return max(1, len(text) // 4)
class Handler(BaseHTTPRequestHandler):
protocol_version = "HTTP/1.1"
def log_message(self, *_):
return
def read_json(self):
length = int(self.headers.get("content-length", "0") or "0")
body = self.rfile.read(length) if length > 0 else b"{}"
return json.loads(body.decode("utf-8") or "{}")
def write_json(self, status, payload):
body = json.dumps(payload, ensure_ascii=False, separators=(",", ":")).encode("utf-8")
self.send_response(status)
self.send_header("content-type", "application/json")
self.send_header("content-length", str(len(body)))
self.send_header("connection", "close")
self.end_headers()
self.wfile.write(body)
self.close_connection = True
def write_raw(self, status, headers, body):
self.send_response(status)
for key, value in headers.items():
lower_key = key.lower()
if lower_key in ("connection", "transfer-encoding", "content-length"):
continue
self.send_header(key, value)
self.send_header("content-length", str(len(body)))
self.send_header("connection", "close")
self.end_headers()
self.wfile.write(body)
self.close_connection = True
def write_sse(self, event, payload):
self.wfile.write(f"event: {event}\n".encode("utf-8"))
self.wfile.write(b"data: ")
self.wfile.write(json.dumps(payload, ensure_ascii=False, separators=(",", ":")).encode("utf-8"))
self.wfile.write(b"\n\n")
self.wfile.flush()
def write_anthropic_stream(self, message):
self.send_response(200)
self.send_header("content-type", "text/event-stream")
self.send_header("cache-control", "no-cache")
self.send_header("connection", "close")
self.end_headers()
self.write_sse(
"message_start",
{
"type": "message_start",
"message": {
"id": message["id"],
"type": "message",
"role": "assistant",
"model": message["model"],
"content": [],
"stop_reason": None,
"stop_sequence": None,
"usage": {"input_tokens": message["usage"]["input_tokens"], "output_tokens": 0},
},
},
)
for index, block in enumerate(message["content"]):
if block.get("type") == "tool_use":
start_block = {
"type": "tool_use",
"id": block.get("id"),
"name": block.get("name"),
"input": {},
}
self.write_sse(
"content_block_start",
{"type": "content_block_start", "index": index, "content_block": start_block},
)
partial_json = json.dumps(block.get("input") or {}, ensure_ascii=False, separators=(",", ":"))
if partial_json:
self.write_sse(
"content_block_delta",
{
"type": "content_block_delta",
"index": index,
"delta": {"type": "input_json_delta", "partial_json": partial_json},
},
)
else:
self.write_sse(
"content_block_start",
{"type": "content_block_start", "index": index, "content_block": {"type": "text", "text": ""}},
)
text = block.get("text") or ""
if text:
self.write_sse(
"content_block_delta",
{
"type": "content_block_delta",
"index": index,
"delta": {"type": "text_delta", "text": text},
},
)
self.write_sse("content_block_stop", {"type": "content_block_stop", "index": index})
self.write_sse(
"message_delta",
{
"type": "message_delta",
"delta": {"stop_reason": message["stop_reason"], "stop_sequence": None},
"usage": {"output_tokens": message["usage"]["output_tokens"]},
},
)
self.write_sse("message_stop", {"type": "message_stop"})
self.close_connection = True
def do_GET(self):
path = urlsplit(self.path).path
if path == "/health":
self.write_json(200, {"ok": True, "models": [model["id"] for model in MODELS]})
return
if path in ("/v1/models", "/anthropic/v1/models"):
self.write_json(200, {"data": MODELS, "first_id": MODELS[0]["id"], "last_id": MODELS[-1]["id"], "has_more": False})
return
self.write_json(404, {"type": "error", "error": {"type": "not_found_error", "message": path}})
def do_POST(self):
path = urlsplit(self.path).path
is_count_tokens = path in ("/v1/messages/count_tokens", "/anthropic/v1/messages/count_tokens")
is_messages = path in ("/v1/messages", "/anthropic/v1/messages")
if not is_count_tokens and not is_messages:
self.write_json(404, {"type": "error", "error": {"type": "not_found_error", "message": path}})
return
try:
payload = self.read_json()
except Exception as exc:
self.write_json(400, {"type": "error", "error": {"type": "invalid_request_error", "message": str(exc)}})
return
headers = {key.lower(): value for key, value in self.headers.items()}
if payload.get("model") == DEV_CORP_MODEL:
if is_count_tokens:
self.write_json(200, {"input_tokens": estimate_tokens(payload)})
return
openai_payload = build_openai_payload(payload)
status, _, body = call_dev_corp(openai_payload, headers)
if status < 200 or status >= 300:
try:
detail = json.loads(body.decode("utf-8"))
except Exception:
detail = body.decode("utf-8", "replace")
self.write_json(
502,
{
"type": "error",
"error": {
"type": "upstream_error",
"message": f"dev-corp upstream returned HTTP {status}",
"detail": detail,
},
},
)
return
try:
openai_response = json.loads(body.decode("utf-8"))
except Exception as exc:
self.write_json(502, {"type": "error", "error": {"type": "upstream_parse_error", "message": str(exc)}})
return
message = openai_to_anthropic(openai_response, payload.get("model"))
if payload.get("stream") is True:
self.write_anthropic_stream(message)
else:
self.write_json(200, message)
return
status, response_headers, body = call_seulgivibe(self.command, path, payload, headers)
if 200 <= status < 300:
body = unwrap_json_string_body(body)
self.write_raw(status, response_headers, body)
class Server(ThreadingHTTPServer):
allow_reuse_address = True
if __name__ == "__main__":
print(f"iop claude gateway listening on http://{HOST}:{PORT} at {time.strftime('%Y-%m-%d %H:%M:%S')}", flush=True)
Server((HOST, PORT), Handler).serve_forever()

34
agent-client/pi/README.md Normal file
View file

@ -0,0 +1,34 @@
# Pi Agent Client
Pi Coding Agent에 Seulgivibe Codex/OpenAI 모델과 Claude 모델을 함께 등록한다.
등록 대상:
```text
seulgivibe-codex/gpt-5.1:medium
seulgivibe-codex/gpt-5.5:xhigh
seulgivibe-claude/claude-sonnet-4-5
seulgivibe-claude/claude-opus-4-8
seulgivibe-claude/claude-fable-5
```
## 설치
```bash
cd agent-client/pi
./install.sh
```
설치 후 이미 실행 중인 Pi session은 `/reload` 하거나 재시작한다.
새 설정 파일을 만드는 환경에서는 기본 모델을 `seulgivibe-codex/gpt-5.5`로 둔다. 기존 기본값이 있으면 보존한다.
## 전제
- Pi는 이미 설치되어 있어야 한다.
- 기존 `~/.pi/agent/settings.json`, `~/.pi/agent/models.json`의 개인 설정과 API key는 보존한다.
- Seulgivibe key가 없으면 `~/.claude/anthropic_key.sh`에서 읽어 넣는다.
- 기존 `seulgivibe-openai` 설정은 `seulgivibe-codex`로 이관한 뒤 제거한다.
- 기존 dev-corp 설정이 있으면 지우지 않고 보존한다.
JWT/API key 원문은 이 폴더에 포함하지 않는다.

222
agent-client/pi/install.sh Executable file
View file

@ -0,0 +1,222 @@
#!/usr/bin/env bash
set -euo pipefail
PI_DIR="$HOME/.pi/agent"
SETTINGS="$PI_DIR/settings.json"
MODELS="$PI_DIR/models.json"
PYTHON_BIN="/usr/bin/python3"
if [[ ! -x "$PYTHON_BIN" ]]; then
PYTHON_BIN="$(command -v python3 || true)"
fi
if [[ -z "$PYTHON_BIN" || ! -x "$PYTHON_BIN" ]]; then
echo "python3 를 찾을 수 없습니다."
exit 1
fi
mkdir -p "$PI_DIR"
"$PYTHON_BIN" - "$SETTINGS" "$MODELS" <<'PY'
import json
import pathlib
import subprocess
import sys
import time
settings_path = pathlib.Path(sys.argv[1]).expanduser()
models_path = pathlib.Path(sys.argv[2]).expanduser()
def read_json(path):
if not path.exists():
return {}
return json.loads(path.read_text() or "{}")
def write_json(path, value):
if path.exists():
backup = path.with_name(f"{path.name}.bak-{time.strftime('%Y%m%d%H%M%S')}")
backup.write_bytes(path.read_bytes())
path.write_text(json.dumps(value, indent=2, ensure_ascii=False) + "\n")
def run_helper(path):
expanded = pathlib.Path(path).expanduser()
if not expanded.exists():
return ""
try:
result = subprocess.run(
[str(expanded)],
check=True,
stdout=subprocess.PIPE,
stderr=subprocess.DEVNULL,
text=True,
timeout=10,
)
except Exception:
return ""
return result.stdout.strip()
def merge_desired_models(existing_models, desired_models):
existing_by_id = {}
for existing in existing_models:
if isinstance(existing, dict) and isinstance(existing.get("id"), str):
existing_by_id.setdefault(existing["id"], existing)
merged_models = []
for desired in desired_models:
merged = dict(existing_by_id.get(desired["id"], {}))
merged.update(desired)
merged_models.append(merged)
return merged_models
def zero_cost():
return {"input": 0, "output": 0, "cacheRead": 0, "cacheWrite": 0}
settings = read_json(settings_path)
models_doc = read_json(models_path)
providers = models_doc.setdefault("providers", {})
seulgivibe_provider = providers.setdefault("seulgivibe-claude", {})
seulgivibe_api_key = seulgivibe_provider.get("apiKey") or run_helper("~/.claude/anthropic_key.sh")
seulgivibe_provider.update(
{
"baseUrl": "https://seulgivibe.lgudax.cool/anthropic",
"api": "anthropic-messages",
"authHeader": True,
"compat": {"supportsEagerToolInputStreaming": False},
}
)
if seulgivibe_api_key:
seulgivibe_provider["apiKey"] = seulgivibe_api_key
seulgivibe_provider["models"] = merge_desired_models(
seulgivibe_provider.get("models", []),
[
{
"id": "claude-sonnet-4-5",
"name": "Seulgivibe Claude Sonnet 4.5",
"reasoning": True,
"input": ["text"],
"contextWindow": 200000,
"maxTokens": 64000,
"cost": zero_cost(),
},
{
"id": "claude-opus-4-8",
"name": "Seulgivibe Claude Opus 4.8",
"reasoning": True,
"thinkingLevelMap": {"xhigh": "xhigh"},
"input": ["text"],
"contextWindow": 1000000,
"maxTokens": 128000,
"compat": {"forceAdaptiveThinking": True, "supportsTemperature": False},
"cost": zero_cost(),
},
{
"id": "claude-fable-5",
"name": "Seulgivibe Claude Fable 5",
"reasoning": True,
"thinkingLevelMap": {"xhigh": "xhigh"},
"input": ["text"],
"contextWindow": 1000000,
"maxTokens": 128000,
"compat": {"forceAdaptiveThinking": True, "supportsTemperature": False},
"cost": zero_cost(),
},
],
)
legacy_openai_provider = providers.get("seulgivibe-openai")
if not isinstance(legacy_openai_provider, dict):
legacy_openai_provider = {}
seulgivibe_codex_provider = providers.setdefault("seulgivibe-codex", {})
seulgivibe_codex_api_key = (
seulgivibe_codex_provider.get("apiKey")
or legacy_openai_provider.get("apiKey")
or run_helper("~/.claude/anthropic_key.sh")
)
seulgivibe_codex_provider.update(
{
"baseUrl": "https://seulgivibe.lgudax.cool/openai/v1",
"api": "openai-responses",
}
)
if seulgivibe_codex_api_key:
seulgivibe_codex_provider["apiKey"] = seulgivibe_codex_api_key
seulgivibe_codex_provider["models"] = merge_desired_models(
seulgivibe_codex_provider.get("models", []),
[
{
"id": "gpt-5.1",
"name": "Seulgivibe Codex GPT-5.1",
"reasoning": True,
"thinkingLevelMap": {"off": "none"},
"input": ["text"],
"contextWindow": 400000,
"maxTokens": 128000,
"cost": zero_cost(),
},
{
"id": "gpt-5.5",
"name": "Seulgivibe Codex GPT-5.5",
"reasoning": True,
"thinkingLevelMap": {"off": "none", "xhigh": "xhigh", "minimal": None},
"input": ["text"],
"contextWindow": 272000,
"maxTokens": 128000,
"cost": zero_cost(),
},
],
)
providers.pop("seulgivibe-openai", None)
settings.setdefault("defaultProvider", "seulgivibe-codex")
settings.setdefault("defaultModel", "gpt-5.5")
settings.setdefault("defaultThinkingLevel", "high")
settings.setdefault("hideThinkingBlock", False)
settings["httpIdleTimeoutMs"] = 300000
retry = settings.setdefault("retry", {})
provider_retry = retry.setdefault("provider", {})
provider_retry["timeoutMs"] = 300000
provider_retry["maxRetries"] = 0
provider_retry["maxRetryDelayMs"] = 60000
enabled = settings.setdefault("enabledModels", [])
if not isinstance(enabled, list):
enabled = []
desired_enabled = [
"seulgivibe-codex/gpt-5.1:medium",
"seulgivibe-codex/gpt-5.5:xhigh",
"seulgivibe-claude/claude-sonnet-4-5",
"seulgivibe-claude/claude-opus-4-8",
"seulgivibe-claude/claude-fable-5",
]
normalized_enabled = []
seen_enabled = set()
for item in enabled:
if not isinstance(item, str):
continue
if item.startswith("seulgivibe-openai/"):
continue
if item.startswith("seulgivibe-codex/") or item.startswith("seulgivibe-claude/"):
continue
if item not in seen_enabled:
normalized_enabled.append(item)
seen_enabled.add(item)
for item in desired_enabled:
if item not in seen_enabled:
normalized_enabled.append(item)
seen_enabled.add(item)
settings["enabledModels"] = normalized_enabled
write_json(settings_path, settings)
write_json(models_path, models_doc)
PY
echo "완료: Pi session에서 /reload 하거나 Pi를 재시작하세요."