diff --git a/agent-client/claude/README.md b/agent-client/claude/README.md index 80bc4fa..a2c449f 100644 --- a/agent-client/claude/README.md +++ b/agent-client/claude/README.md @@ -1,13 +1,11 @@ # Claude Agent Client -기존 Seulgivibe Claude 설정이 되어 있는 macOS 환경에서 Claude TUI `/model`에 아래 모델을 함께 노출한다. +기존 Seulgivibe Claude 설정이 되어 있는 macOS 환경에서 Claude 기본 모델은 그대로 두고, Claude TUI `/model`에 아래 gateway 모델만 추가로 노출한다. ```text -claude-seulgivibe-sonnet-4-5 Claude Sonnet 4.5 - coding agents -claude-seulgivibe-opus-4-8 Claude Opus 4.8 - browser agents -claude-seulgivibe-mythos-5 Claude Fable 5 - autonomous work -claude-dev-corp-gemma4-26b Gemma4 26B - gemma4:26b -claude-dev-corp-ornith35b Ornith 35B - ornith35b +claude-seulgivibe-mythos-5 Claude Fable 5 +claude-dev-corp-gemma4-26b Gemma 4 26B A4B IT NVFP4 +claude-dev-corp-ornith35b Ornith 1.0 35B 8-bit ``` ## 설치 @@ -27,6 +25,8 @@ cd agent-client/claude - `~/.claude/dev-corp-openai-api-key` - `~/.pi/agent/dev-corp-openai-api-key` - 없으면 `~/.claude/anthropic_key.sh` 출력값을 fallback으로 사용한다. +- 기본 Claude 모델은 gateway 모델로 재등록하지 않고 Claude Code 기본 표시를 사용한다. +- `~/.claude/use-model.sh dev-corp|ornith|fable|sonnet|opus`로 로컬 모델 프로필을 전환할 수 있다. - `claude-dev-corp-gemma4-26b`는 Claude 표시용 alias이며 upstream 호출 모델은 `gemma4:26b`다. - `claude-dev-corp-ornith35b`는 Claude 표시용 alias이며 upstream 호출 모델은 `ornith:35b`다. diff --git a/agent-client/claude/install.sh b/agent-client/claude/install.sh index 565e727..0ca03cf 100755 --- a/agent-client/claude/install.sh +++ b/agent-client/claude/install.sh @@ -6,10 +6,12 @@ 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" +USE_MODEL="$CLAUDE_DIR/use-model.sh" LABEL="com.iop.agent-client.claude" PLIST="$HOME/Library/LaunchAgents/$LABEL.plist" PYTHON_BIN="/usr/bin/python3" UID_VALUE="$(id -u)" +GATEWAY_PORT="${IOP_CLAUDE_GATEWAY_PORT:-18444}" if [[ ! -x "$PYTHON_BIN" ]]; then PYTHON_BIN="$(command -v python3 || true)" @@ -29,6 +31,7 @@ mkdir -p "$CLAUDE_DIR/cache" "$HOME/Library/LaunchAgents" OLD_LABELS=( "$LABEL" + "com.leedongmyung.dev-corp-claude-gateway" "com.seulgivibe.claude.models" "com.seulgivibe.claude.anthropic-proxy" "com.leedongmyung.claude.seulgivibe-anthropic-proxy" @@ -40,6 +43,7 @@ done OLD_PLISTS=( "$PLIST" + "$HOME/Library/LaunchAgents/com.leedongmyung.dev-corp-claude-gateway.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" @@ -86,69 +90,136 @@ EOF chmod 755 "$DEV_CORP_KEY_HELPER" fi -"$PYTHON_BIN" - "$SETTINGS" "$CLAUDE_DIR/cache/gateway-models.json" <<'PY' +"$PYTHON_BIN" - "$SETTINGS" "$CLAUDE_DIR" "$GATEWAY_PORT" <<'PY' import json import pathlib import sys import time settings_path = pathlib.Path(sys.argv[1]) -cache_path = pathlib.Path(sys.argv[2]) +claude_dir = pathlib.Path(sys.argv[2]) +gateway_port = sys.argv[3] 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 "{}") + try: + settings = json.loads(settings_path.read_text() or "{}") + except Exception: + settings = {} -models = [ - "claude-seulgivibe-sonnet-4-5", - "claude-seulgivibe-opus-4-8", - "claude-seulgivibe-mythos-5", - "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 [ +base_url = f"http://127.0.0.1:{gateway_port}/anthropic" +common_env = { + "ANTHROPIC_BASE_URL": base_url, + "NODE_TLS_REJECT_UNAUTHORIZED": "0", + "CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY": "1", +} +removed_env_keys = [ "ANTHROPIC_CUSTOM_MODEL_OPTION", "ANTHROPIC_CUSTOM_MODEL_OPTION_NAME", "ANTHROPIC_CUSTOM_MODEL_OPTION_DESCRIPTION", + "ANTHROPIC_CUSTOM_MODEL_OPTION_SUPPORTED_CAPABILITIES", "ANTHROPIC_DEFAULT_FABLE_MODEL", "ANTHROPIC_DEFAULT_FABLE_MODEL_NAME", "ANTHROPIC_DEFAULT_FABLE_MODEL_DESCRIPTION", -]: - env.pop(key, None) +] +model_aliases = { + "claude-seulgivibe-sonnet-4-5": "sonnet", + "claude-seulgivibe-opus-4-8": "opus", + "gemma4:26b": "claude-dev-corp-gemma4-26b", + "ornith35b": "claude-dev-corp-ornith35b", + "ornith:35b": "claude-dev-corp-ornith35b", +} -if settings.get("model") == "claude-dev-corp-gemma4-26b": - settings["model"] = "gemma4:26b" -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) +def normalize_settings(value, model=None): + output = dict(value or {}) + output["apiKeyHelper"] = output.get("apiKeyHelper") or "~/.claude/anthropic_key.sh" + env = dict(output.get("env") or {}) + for key in removed_env_keys: + env.pop(key, None) + env.update(common_env) + output["env"] = env + output.pop("availableModels", None) + output.pop("enforceAvailableModels", None) + if model is not None: + output["model"] = model + elif output.get("model") in model_aliases: + output["model"] = model_aliases[output["model"]] + output.setdefault("permissions", {}) + output["permissions"].setdefault("defaultMode", "bypassPermissions") + output["permissions"].setdefault("skipDangerousModePermissionPrompt", True) + output.setdefault("effortLevel", "xhigh") + output.setdefault("alwaysThinkingEnabled", True) + output.setdefault("skipDangerousModePermissionPrompt", True) + return output +settings = normalize_settings(settings) 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": "gemma4:26b", "display_name": "dev-corp gemma4:26b"}, - ], +profiles = { + "settings.dev-corp.json": "claude-dev-corp-gemma4-26b", + "settings.ornith35b.json": "claude-dev-corp-ornith35b", + "settings.fable.json": "claude-seulgivibe-mythos-5", + "settings.sonnet-4-5.json": "sonnet", + "settings.opus-4-8.json": "opus", } -cache_path.write_text(json.dumps(cache, ensure_ascii=False, separators=(",", ":")) + "\n") +for filename, model in profiles.items(): + profile_path = claude_dir / filename + profile = {} + if profile_path.exists(): + try: + profile = json.loads(profile_path.read_text() or "{}") + except Exception: + profile = {} + profile = normalize_settings(profile, model=model) + profile_path.write_text(json.dumps(profile, indent=2, ensure_ascii=False) + "\n") PY +cat > "$USE_MODEL" <<'EOF' +#!/bin/sh +set -eu + +profile="${1:-}" +case "$profile" in + dev-corp|devcorp|gemma|gemma4) + source_file="$HOME/.claude/settings.dev-corp.json" + ;; + ornith|ornith35b) + source_file="$HOME/.claude/settings.ornith35b.json" + ;; + fable) + source_file="$HOME/.claude/settings.fable.json" + ;; + sonnet|sonnet-4-5) + source_file="$HOME/.claude/settings.sonnet-4-5.json" + ;; + opus|opus-4-8) + source_file="$HOME/.claude/settings.opus-4-8.json" + ;; + *) + echo "Usage: $0 dev-corp|ornith|fable|sonnet|opus" >&2 + exit 2 + ;; +esac + +mkdir -p "$HOME/.claude/backups" +if [ -f "$HOME/.claude/settings.json" ]; then + cp "$HOME/.claude/settings.json" "$HOME/.claude/backups/settings.json.$(date +%Y%m%d-%H%M%S)" +fi + +cp "$source_file" "$HOME/.claude/settings.json" + +if command -v jq >/dev/null 2>&1; then + jq -r '"Claude Code model set to " + .model + " / effort=" + (.effortLevel // "default")' "$HOME/.claude/settings.json" +else + echo "Claude Code model profile applied: $profile" +fi +EOF +chmod 755 "$USE_MODEL" +rm -f "$CLAUDE_DIR/cache/gateway-models.json" + cat > "$PLIST" < @@ -161,6 +232,13 @@ cat > "$PLIST" <$PYTHON_BIN $GATEWAY + EnvironmentVariables + + IOP_CLAUDE_GATEWAY_PORT + $GATEWAY_PORT + PATH + /opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin + RunAtLoad KeepAlive @@ -177,9 +255,9 @@ 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 + curl -fsS "http://127.0.0.1:$GATEWAY_PORT/health" >/dev/null 2>&1 && break sleep 1 done -curl -fsS "http://127.0.0.1:18443/anthropic/v1/models" >/dev/null +curl -fsS "http://127.0.0.1:$GATEWAY_PORT/anthropic/v1/models" >/dev/null echo "완료: Claude TUI를 새로 열고 /model 에서 모델을 선택하세요." diff --git a/agent-client/claude/iop-claude-gateway.py b/agent-client/claude/iop-claude-gateway.py index f63448e..d03392d 100755 --- a/agent-client/claude/iop-claude-gateway.py +++ b/agent-client/claude/iop-claude-gateway.py @@ -11,7 +11,7 @@ from urllib.parse import urlsplit HOST = "127.0.0.1" -PORT = int(os.environ.get("IOP_CLAUDE_GATEWAY_PORT", "18443")) +PORT = int(os.environ.get("IOP_CLAUDE_GATEWAY_PORT", "18444")) SEULGIVIBE_HOST = os.environ.get("SEULGIVIBE_HOST", "seulgivibe.lgudax.cool") SEULGIVIBE_PREFIX = os.environ.get("SEULGIVIBE_ANTHROPIC_PREFIX", "/anthropic") @@ -37,7 +37,7 @@ GEMMA_OPENAI_BASE_URL = os.environ.get( ).rstrip("/") ORNITH_OPENAI_BASE_URL = os.environ.get( "DEV_CORP_ORNITH_OPENAI_BASE_URL", - "http://172.24.63.178:18003/v1", + DEFAULT_DEV_CORP_OPENAI_BASE_URL, ).rstrip("/") DEV_CORP_MODEL_CONFIGS = { GEMMA_MODEL: { @@ -59,45 +59,29 @@ DEV_CORP_MODEL_CONFIGS = { } 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": "Claude Sonnet 4.5 - coding agents", - "description": "Seulgivibe Claude Sonnet 4.5 for coding, complex agents, computer use, and long-horizon tasks.", - "created_at": "2026-05-13T00:00:00Z", - }, - { - "id": "claude-seulgivibe-opus-4-8", - "type": "model", - "display_name": "Claude Opus 4.8 - browser agents", - "description": "Seulgivibe Claude Opus 4.8 for deeper agent work, browser and computer-use tasks, and long-session collaboration.", - "created_at": "2026-05-13T00:00:00Z", - }, { "id": "claude-seulgivibe-mythos-5", "type": "model", - "display_name": "Claude Fable 5 - autonomous work", - "description": "Seulgivibe Claude Fable 5 for complex knowledge work, coding, multi-day autonomous tasks, staged planning, delegation, and self-verification.", + "display_name": "Claude Fable 5", + "description": "Official: next-generation model for complex knowledge work, coding, and sustained multi-day autonomous tasks.", "created_at": "2026-05-13T00:00:00Z", }, { "id": CLAUDE_GEMMA_MODEL, "type": "model", - "display_name": "Gemma4 26B - gemma4:26b", - "description": "dev-corp Gemma 4 26B A4B IT NVFP4 via MLX/OpenAI-compatible, tuned here for image-text and local code workflows.", + "display_name": "Gemma 4 26B A4B IT NVFP4", + "description": "Model card: MLX Image-Text-to-Text Gemma 4 26B A4B IT NVFP4 model, served here as gemma4:26b.", "created_at": "2026-07-10T00:00:00Z", }, { "id": CLAUDE_ORNITH_MODEL, "type": "model", - "display_name": "Ornith 35B - ornith35b", - "description": "dev-corp Ornith 1.0 35B 8-bit MLX open coding agent, routed to upstream ornith:35b.", + "display_name": "Ornith 1.0 35B 8-bit", + "description": "Model card: MLX 8-bit Ornith 1.0 35B from the open-source self-improving agentic coding family.", "created_at": "2026-07-10T00:00:00Z", }, ] @@ -222,6 +206,7 @@ def seulgivibe_path(raw_path): def call_seulgivibe(method, raw_path, payload, inbound_headers): outbound_payload = dict(payload) + outbound_payload.pop("context_management", None) model = outbound_payload.get("model") if model in SEULGIVIBE_ALIASES: normalize_system_messages(outbound_payload) @@ -265,10 +250,10 @@ def unwrap_json_string_body(body): def anthropic_messages_to_openai(payload): messages = [] - system_text = normalize_system(payload.get("system")) - if system_text: - messages.append({"role": "system", "content": system_text}) - + system_parts = [] + initial_system_text = normalize_system(payload.get("system")) + if initial_system_text: + system_parts.append(initial_system_text) tool_names = {} for message in payload.get("messages") or []: if not is_record(message): @@ -276,6 +261,12 @@ def anthropic_messages_to_openai(payload): role = message.get("role") content = message.get("content") + if role in ("system", "developer"): + text = content_to_text(content) + if text: + system_parts.append(text) + continue + if role == "assistant": text_parts = [] tool_calls = [] @@ -331,10 +322,8 @@ def anthropic_messages_to_openai(payload): 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}) + if system_parts: + messages.insert(0, {"role": "system", "content": "\n\n".join(system_parts)}) return messages