Files
ai-setup/claude_setup.sh
vitaly 42546b4dc1 feat: migrate Kimi from Moonshot to Artemox OpenAI API
- Add claude-openai-anthropic-proxy.py (Anthropic ↔ OpenAI translator)
- Rewrite claude_kimi to use native kimi CLI instead of Claude wrapper
- Add OpenAI-compatible API test helpers (_claude_test_openai_api, _handle_openai_api_response)
- Replace 127.0.0.1 with localhost for broader compatibility
- Add test for query-string handling in Kimi proxy
- Update README to reflect Artemox API usage
2026-05-31 17:55:57 +07:00

1152 lines
42 KiB
Bash
Executable File
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env bash
# ============================================================
# Claude Code Setup — GPT-5.5 / DeepSeek / Kimi / Gemini
# Запуск: bash claude_setup.sh
# ============================================================
CONFIG_DIR="$HOME/.config/claude-launcher"
BIN_DIR="$HOME/.local/bin"
NPM_GLOBAL="$HOME/.npm-global"
PROXY_BIN="$BIN_DIR/claude-code-proxy"
RED='\033[0;31m'; GREEN='\033[0;32m'; YELLOW='\033[1;33m'; CYAN='\033[0;36m'; NC='\033[0m'
info() { echo -e "${CYAN}[INFO]${NC} $*"; }
success() { echo -e "${GREEN}[OK]${NC} $*"; }
warn() { echo -e "${YELLOW}[WARN]${NC} $*"; }
err() { echo -e "${RED}[ERR]${NC} $*"; exit 1; }
# Запрет запуска от root
if [ "$EUID" -eq 0 ]; then
echo -e "${RED}Не запускайте этот скрипт через sudo!${NC}"
echo "Запустите просто: bash claude_setup.sh"
exit 1
fi
info "Проверяю зависимости (python3)..."
if ! command -v python3 &>/dev/null; then
err "Python 3 не установлен, но он требуется для работы скрипта."
fi
success "Python 3 найден"
# ── 1. npm prefix в домашнюю папку ──────────────────────────
info "Настраиваю npm prefix..."
mkdir -p "$NPM_GLOBAL"
npm config set prefix "$NPM_GLOBAL"
success "npm prefix -> $NPM_GLOBAL"
export PATH="$NPM_GLOBAL/bin:$BIN_DIR:$PATH"
# ── 2. Node.js ───────────────────────────────────────────────
info "Проверяю Node.js..."
if ! command -v node &>/dev/null; then
info "Попытка установки Node.js (нужен sudo)..."
if command -v apt-get &>/dev/null; then
curl -fsSL https://deb.nodesource.com/setup_22.x | sudo -E bash -
sudo apt-get install -y nodejs
elif command -v dnf &>/dev/null; then
sudo dnf install -y nodejs
else
warn "Не удалось определить пакетный менеджер. Установите Node.js вручную."
fi
fi
if command -v node &>/dev/null; then
success "Node.js $(node --version)"
else
warn "Node.js не найден. Некоторые функции могут не работать."
fi
# ── 3. Claude Code ───────────────────────────────────────────
info "Проверяю Claude Code..."
if ! command -v claude &>/dev/null; then
info "Устанавливаю Claude Code..."
if curl -fsSL https://claude.ai/install.sh | bash 2>/dev/null; then
success "Claude Code установлен (официальный инсталлер)"
else
npm install -g @anthropic-ai/claude-code
success "Claude Code установлен (npm)"
fi
else
success "Claude Code уже установлен: $(claude --version 2>/dev/null | head -1)"
fi
# ── 4. claude-code-proxy (GPT) ───────────────────────────────
mkdir -p "$BIN_DIR"
install_proxy() {
info "Устанавливаю claude-code-proxy..."
ARCH=$(uname -m)
case "$ARCH" in
x86_64) ARCH_TAG="amd64" ;;
aarch64) ARCH_TAG="arm64" ;;
*) err "Неизвестная архитектура: $ARCH" ;;
esac
LATEST=$(curl -fsSL "https://api.github.com/repos/raine/claude-code-proxy/releases/latest" \
| grep '"tag_name"' | sed 's/.*"tag_name": *"\(.*\)".*/\1/')
[ -z "$LATEST" ] && err "Не удалось получить версию claude-code-proxy с GitHub"
TMP=$(mktemp -d)
trap 'rm -rf "$TMP"' EXIT
URL="https://github.com/raine/claude-code-proxy/releases/download/${LATEST}/claude-code-proxy-linux-${ARCH_TAG}.tar.gz"
info "Скачиваю $URL"
curl -fsSL "$URL" -o "$TMP/proxy.tar.gz" || err "Не удалось скачать claude-code-proxy"
tar -xzf "$TMP/proxy.tar.gz" -C "$TMP"
BINARY=$(find "$TMP" -name "claude-code-proxy" -type f | head -1)
[ -z "$BINARY" ] && err "Бинарник не найден в архиве"
cp "$BINARY" "$PROXY_BIN"
chmod +x "$PROXY_BIN"
success "claude-code-proxy $LATEST -> $PROXY_BIN"
}
if [ -f "$PROXY_BIN" ]; then
CURRENT_VER=$("$PROXY_BIN" --version 2>/dev/null | head -1 || echo "unknown")
success "claude-code-proxy уже установлен ($CURRENT_VER)"
else
install_proxy
fi
# ── 4b. effort-proxy wrapper (маппинг effort: xhigh→high) ─────────
EFFORT_PROXY_BIN="$BIN_DIR/claude-gpt-effort-proxy.py"
cat > "$EFFORT_PROXY_BIN" << 'PYEOF'
#!/usr/bin/env python3
"""Effort mapping proxy for GPT backend.
claude-code-proxy now accepts: low, medium, high, max (no "xhigh").
Claude Code may send "xhigh" effort — we map it to "high".
"""
import http.client, http.server, sys, logging, socketserver
UPSTREAM_PORT = int(sys.argv[1]) if len(sys.argv) > 1 else 18766
LISTEN_PORT = int(sys.argv[2]) if len(sys.argv) > 2 else 18765
logging.basicConfig(
filename="/tmp/claude-gpt-effort-proxy.log",
level=logging.INFO,
format='{"t":"%(asctime)s","level":"%(levelname)s","msg":"%(message)s"}',
datefmt='%Y-%m-%dT%H:%M:%S',
)
log = logging.getLogger("effort-proxy")
# Потокобезопасный сервер — обрабатывает несколько запросов одновременно
class _ThreadedServer(socketserver.ThreadingMixIn, http.server.HTTPServer):
daemon_threads = True
class _Proxy(http.server.BaseHTTPRequestHandler):
def proxy_request(self):
body = b""
if cl := self.headers.get("Content-Length"):
body = self.rfile.read(int(cl))
# Маппинг xhigh→high: claude-code-proxy больше не принимает xhigh
body = body.replace(b'"xhigh"', b'"high"')
try:
conn = http.client.HTTPConnection("127.0.0.1", UPSTREAM_PORT, timeout=300)
hdrs = dict(self.headers)
hdrs.pop("Host", None)
hdrs["Content-Length"] = str(len(body))
conn.request(self.command, self.path, body=body or None, headers=hdrs)
resp = conn.getresponse()
self.send_response(resp.status, resp.reason)
chunked = False
for k, v in resp.getheaders():
if k.lower() == "transfer-encoding" and "chunked" in v.lower():
chunked = True
self.send_header(k, v)
self.end_headers()
while chunk := resp.read(4096):
try:
if chunked:
self.wfile.write(f"{len(chunk):X}\r\n".encode() + chunk + b"\r\n")
else:
self.wfile.write(chunk)
self.wfile.flush()
except (BrokenPipeError, ConnectionResetError):
break
if chunked:
try: self.wfile.write(b"0\r\n\r\n")
except: pass
conn.close()
except Exception as e:
log.error("proxy error: %s", e)
try: self.send_error(502, str(e))
except: pass
do_GET = do_POST = do_PUT = do_DELETE = do_HEAD = proxy_request
def log_message(self, *args): pass
log.info("effort-proxy starting on 127.0.0.1:%d → upstream %d", LISTEN_PORT, UPSTREAM_PORT)
_ThreadedServer(("127.0.0.1", LISTEN_PORT), _Proxy).serve_forever()
PYEOF
chmod +x "$EFFORT_PROXY_BIN"
success "claude-gpt-effort-proxy -> $EFFORT_PROXY_BIN"
# ── 4c. openai-anthropic-proxy (Kimi) ──────────────────────
OPENAI_ANTHROPIC_PROXY_BIN="$BIN_DIR/claude-openai-anthropic-proxy.py"
cat > "$OPENAI_ANTHROPIC_PROXY_BIN" << 'PYEOF'
#!/usr/bin/env python3
"""Small Anthropic Messages → OpenAI Chat Completions proxy."""
import argparse
import http.client
import http.server
import json
import os
import socketserver
import sys
import urllib.parse
def _json_dumps(obj):
return json.dumps(obj, ensure_ascii=False, separators=(",", ":"))
def _read_json(handler):
length = int(handler.headers.get("Content-Length", "0"))
if length <= 0:
return {}
return json.loads(handler.rfile.read(length).decode("utf-8"))
def _system_to_text(system):
if isinstance(system, str):
return system
if isinstance(system, list):
parts = []
for item in system:
if isinstance(item, dict) and item.get("type") == "text":
parts.append(item.get("text", ""))
elif isinstance(item, str):
parts.append(item)
return "\n".join(p for p in parts if p)
return ""
def _content_to_openai(content):
if isinstance(content, str):
return content
if not isinstance(content, list):
return str(content)
text_parts = []
for block in content:
if not isinstance(block, dict):
text_parts.append(str(block))
continue
btype = block.get("type")
if btype == "text":
text_parts.append(block.get("text", ""))
elif btype == "tool_result":
value = block.get("content", "")
if isinstance(value, list):
value = "\n".join(
part.get("text", "") if isinstance(part, dict) else str(part)
for part in value
)
text_parts.append(str(value))
return "\n".join(p for p in text_parts if p)
def _messages_to_openai(body):
messages = []
system = _system_to_text(body.get("system"))
if system:
messages.append({"role": "system", "content": system})
pending_tool_results = []
for msg in body.get("messages", []):
role = msg.get("role", "user")
content = msg.get("content", "")
if isinstance(content, list) and role == "user":
normal_blocks = []
for block in content:
if isinstance(block, dict) and block.get("type") == "tool_result":
tool_content = block.get("content", "")
if isinstance(tool_content, list):
tool_content = _content_to_openai(tool_content)
pending_tool_results.append({
"role": "tool",
"tool_call_id": block.get("tool_use_id", "tool_call"),
"content": str(tool_content),
})
else:
normal_blocks.append(block)
if normal_blocks:
messages.append({"role": "user", "content": _content_to_openai(normal_blocks)})
messages.extend(pending_tool_results)
pending_tool_results = []
continue
converted = {"role": role, "content": _content_to_openai(content)}
if isinstance(content, list) and role == "assistant":
tool_calls = []
for block in content:
if isinstance(block, dict) and block.get("type") == "tool_use":
tool_calls.append({
"id": block.get("id", "tool_call"),
"type": "function",
"function": {
"name": block.get("name", "tool"),
"arguments": _json_dumps(block.get("input", {})),
},
})
if tool_calls:
converted["tool_calls"] = tool_calls
messages.append(converted)
return messages
def _tools_to_openai(tools):
result = []
for tool in tools or []:
result.append({
"type": "function",
"function": {
"name": tool.get("name", "tool"),
"description": tool.get("description", ""),
"parameters": tool.get("input_schema", {"type": "object", "properties": {}}),
},
})
return result
def _tool_choice_to_openai(choice):
if not choice:
return None
if isinstance(choice, str):
return choice
ctype = choice.get("type")
if ctype == "auto":
return "auto"
if ctype == "any":
return "required"
if ctype == "tool":
return {"type": "function", "function": {"name": choice.get("name", "")}}
return None
def _to_openai_request(body, model):
out = {
"model": model or body.get("model"),
"messages": _messages_to_openai(body),
"stream": bool(body.get("stream")),
}
if "max_tokens" in body:
out["max_tokens"] = body["max_tokens"]
for key in ("temperature", "top_p"):
if key in body:
out[key] = body[key]
if body.get("stop_sequences"):
out["stop"] = body["stop_sequences"]
tools = _tools_to_openai(body.get("tools"))
if tools:
out["tools"] = tools
choice = _tool_choice_to_openai(body.get("tool_choice"))
if choice:
out["tool_choice"] = choice
return out
def _finish_reason(reason):
return {
"stop": "end_turn",
"length": "max_tokens",
"tool_calls": "tool_use",
"content_filter": "stop_sequence",
}.get(reason or "stop", "end_turn")
def _anthropic_response(openai_body, model):
choice = (openai_body.get("choices") or [{}])[0]
message = choice.get("message") or {}
content = []
if message.get("content"):
content.append({"type": "text", "text": message.get("content")})
for call in message.get("tool_calls") or []:
fn = call.get("function") or {}
raw_args = fn.get("arguments") or "{}"
try:
args = json.loads(raw_args)
except Exception:
args = {"arguments": raw_args}
content.append({
"type": "tool_use",
"id": call.get("id", "tool_call"),
"name": fn.get("name", "tool"),
"input": args,
})
usage = openai_body.get("usage") or {}
return {
"id": openai_body.get("id", "msg_openai_proxy"),
"type": "message",
"role": "assistant",
"model": model,
"content": content or [{"type": "text", "text": ""}],
"stop_reason": _finish_reason(choice.get("finish_reason")),
"stop_sequence": None,
"usage": {
"input_tokens": usage.get("prompt_tokens", 0),
"output_tokens": usage.get("completion_tokens", 0),
},
}
def _write_json(handler, status, obj):
data = _json_dumps(obj).encode("utf-8")
handler.send_response(status)
handler.send_header("Content-Type", "application/json")
handler.send_header("Content-Length", str(len(data)))
handler.end_headers()
handler.wfile.write(data)
def _openai_error_to_anthropic(status, raw):
try:
data = json.loads(raw.decode("utf-8"))
except Exception:
data = {"error": raw.decode("utf-8", "replace")}
err = data.get("error", data)
if isinstance(err, dict):
message = err.get("message") or _json_dumps(err)
etype = err.get("type") or "api_error"
else:
message = str(err)
etype = "api_error"
return {"type": "error", "error": {"type": etype, "message": message}}
class _ThreadedServer(socketserver.ThreadingMixIn, http.server.HTTPServer):
daemon_threads = True
class Proxy(http.server.BaseHTTPRequestHandler):
upstream_base = ""
api_key = ""
model = ""
def log_message(self, *args):
pass
def do_GET(self):
path = urllib.parse.urlparse(self.path).path
if path in ("/", "/health"):
_write_json(self, 200, {"ok": True})
else:
_write_json(self, 404, {"type": "error", "error": {"message": "not found"}})
def do_POST(self):
path = urllib.parse.urlparse(self.path).path
if not path.endswith("/messages"):
_write_json(self, 404, {"type": "error", "error": {"message": "not found"}})
return
try:
body = _read_json(self)
openai_body = _to_openai_request(body, self.model)
if openai_body.get("stream"):
self._proxy_stream(openai_body)
else:
self._proxy_json(openai_body)
except Exception as exc:
_write_json(self, 500, {"type": "error", "error": {"type": "proxy_error", "message": str(exc)}})
def _request_upstream(self, payload):
parsed = urllib.parse.urlparse(self.upstream_base.rstrip("/") + "/chat/completions")
conn_cls = http.client.HTTPSConnection if parsed.scheme == "https" else http.client.HTTPConnection
conn = conn_cls(parsed.netloc, timeout=600)
path = parsed.path or "/chat/completions"
if parsed.query:
path += "?" + parsed.query
conn.request(
"POST",
path,
body=_json_dumps(payload).encode("utf-8"),
headers={
"Authorization": "Bearer " + self.api_key,
"Content-Type": "application/json",
},
)
return conn, conn.getresponse()
def _proxy_json(self, payload):
conn, resp = self._request_upstream(payload)
raw = resp.read()
conn.close()
if resp.status >= 400:
_write_json(self, resp.status, _openai_error_to_anthropic(resp.status, raw))
return
_write_json(self, resp.status, _anthropic_response(json.loads(raw.decode("utf-8")), self.model))
def _sse(self, event, data):
self.wfile.write(("event: " + event + "\n").encode("utf-8"))
self.wfile.write(("data: " + _json_dumps(data) + "\n\n").encode("utf-8"))
self.wfile.flush()
def _proxy_stream(self, payload):
conn, resp = self._request_upstream(payload)
if resp.status >= 400:
raw = resp.read()
conn.close()
_write_json(self, resp.status, _openai_error_to_anthropic(resp.status, raw))
return
self.send_response(200)
self.send_header("Content-Type", "text/event-stream")
self.send_header("Cache-Control", "no-cache")
self.end_headers()
msg_id = "msg_openai_proxy"
input_tokens = 0
output_tokens = 0
finish = "end_turn"
text_started = False
text_index = 0
tool_indexes = {}
tool_buffers = {}
next_index = 0
self._sse("message_start", {
"type": "message_start",
"message": {
"id": msg_id,
"type": "message",
"role": "assistant",
"model": self.model,
"content": [],
"stop_reason": None,
"stop_sequence": None,
"usage": {"input_tokens": 0, "output_tokens": 0},
},
})
self._sse("ping", {"type": "ping"})
while True:
line = resp.readline()
if not line:
break
line = line.decode("utf-8", "replace").strip()
if not line or not line.startswith("data:"):
continue
data = line[5:].strip()
if data == "[DONE]":
break
try:
chunk = json.loads(data)
except Exception:
continue
usage = chunk.get("usage") or {}
input_tokens = usage.get("prompt_tokens", input_tokens)
output_tokens = usage.get("completion_tokens", output_tokens)
choice = (chunk.get("choices") or [{}])[0]
if choice.get("finish_reason"):
finish = _finish_reason(choice.get("finish_reason"))
delta = choice.get("delta") or {}
text = delta.get("content")
if text:
if not text_started:
text_started = True
text_index = next_index
next_index += 1
self._sse("content_block_start", {
"type": "content_block_start",
"index": text_index,
"content_block": {"type": "text", "text": ""},
})
self._sse("content_block_delta", {
"type": "content_block_delta",
"index": text_index,
"delta": {"type": "text_delta", "text": text},
})
for call in delta.get("tool_calls") or []:
cidx = call.get("index", 0)
if cidx not in tool_indexes:
tool_indexes[cidx] = next_index
next_index += 1
tool_buffers[cidx] = {"id": call.get("id", "tool_call"), "name": "tool", "args": ""}
buf = tool_buffers[cidx]
if call.get("id"):
buf["id"] = call["id"]
fn = call.get("function") or {}
if fn.get("name"):
buf["name"] = fn["name"]
if fn.get("arguments"):
buf["args"] += fn["arguments"]
conn.close()
# Fallback: если модель потратила все токены на reasoning и не выдала
# ни одного content-блока, отправляем пустой текстовый блок (Anthropic
# требует минимум один content block в ответе).
if not text_started and not tool_indexes:
text_started = True
text_index = next_index
next_index += 1
self._sse("content_block_start", {
"type": "content_block_start",
"index": text_index,
"content_block": {"type": "text", "text": ""},
})
self._sse("content_block_delta", {
"type": "content_block_delta",
"index": text_index,
"delta": {"type": "text_delta", "text": ""},
})
if text_started:
self._sse("content_block_stop", {"type": "content_block_stop", "index": text_index})
for cidx, index in sorted(tool_indexes.items(), key=lambda item: item[1]):
buf = tool_buffers[cidx]
try:
parsed_args = json.loads(buf["args"] or "{}")
except Exception:
parsed_args = {"arguments": buf["args"]}
self._sse("content_block_start", {
"type": "content_block_start",
"index": index,
"content_block": {
"type": "tool_use",
"id": buf["id"],
"name": buf["name"],
"input": parsed_args,
},
})
self._sse("content_block_stop", {"type": "content_block_stop", "index": index})
self._sse("message_delta", {
"type": "message_delta",
"delta": {"stop_reason": finish, "stop_sequence": None},
"usage": {"output_tokens": output_tokens},
})
self._sse("message_stop", {"type": "message_stop"})
def main():
parser = argparse.ArgumentParser()
parser.add_argument("--host", default="127.0.0.1")
parser.add_argument("--port", type=int, default=int(os.environ.get("OPENAI_ANTHROPIC_PROXY_PORT", "18767")))
args = parser.parse_args()
Proxy.upstream_base = os.environ.get("OPENAI_BASE_URL", "https://api.artemox.com/v1")
Proxy.api_key = os.environ.get("OPENAI_API_KEY", "")
Proxy.model = os.environ.get("OPENAI_MODEL", "kimi-k2.6")
if not Proxy.api_key:
print("OPENAI_API_KEY is required", file=sys.stderr)
return 1
_ThreadedServer((args.host, args.port), Proxy).serve_forever()
return 0
if __name__ == "__main__":
raise SystemExit(main())
PYEOF
chmod +x "$OPENAI_ANTHROPIC_PROXY_BIN"
success "claude-openai-anthropic-proxy -> $OPENAI_ANTHROPIC_PROXY_BIN"
# ── 5. antigravity-claude-proxy (Gemini) ────────────────────
info "Проверяю antigravity-claude-proxy..."
if command -v antigravity-claude-proxy &>/dev/null || command -v acc &>/dev/null; then
success "antigravity-claude-proxy уже установлен"
else
info "Устанавливаю antigravity-claude-proxy (npm, без sudo)..."
npm install -g antigravity-claude-proxy@latest
success "antigravity-claude-proxy установлен"
fi
# ── 6. Папка для конфигов ────────────────────────────────────
mkdir -p "$CONFIG_DIR"
# ── 7. Очистка старых функций из .bashrc / .zshrc ───────────
clean_rc() {
local rc_file="$1"
if [ -f "$rc_file" ] && grep -q "# === CLAUDE LAUNCHER ===" "$rc_file"; then
info "Очищаю старые функции из $rc_file..."
python3 - "$rc_file" <<'PYEOF'
import sys
path = sys.argv[1]
with open(path, 'r') as f:
content = f.read()
marker = '# === CLAUDE LAUNCHER ==='
end_marker = '# === END CLAUDE LAUNCHER ==='
start = content.find(marker)
end = content.find(end_marker)
if start != -1 and end != -1:
new_content = content[:start] + content[end + len(end_marker):].lstrip('\n')
with open(path, 'w') as f:
f.write(new_content)
PYEOF
success "Старые функции удалены из $rc_file"
fi
}
clean_rc "$HOME/.bashrc"
clean_rc "$HOME/.zshrc"
add_path_to_rc() {
local rc_file="$1"
if [ -f "$rc_file" ]; then
if ! grep -q 'NPM_GLOBAL' "$rc_file" 2>/dev/null; then
cat >> "$rc_file" << 'PATHEOF'
# Claude Code Launcher PATH
export NPM_GLOBAL="$HOME/.npm-global"
export PATH="$NPM_GLOBAL/bin:$HOME/.local/bin:$PATH"
PATHEOF
success "PATH добавлен в $rc_file"
fi
fi
}
add_path_to_rc "$HOME/.bashrc"
[ -f "$HOME/.zshrc" ] && add_path_to_rc "$HOME/.zshrc"
# ── 8. Генерация Standalone скриптов ────────────────────────
info "Генерирую standalone скрипты в $BIN_DIR..."
HELPERS_FILE="$BIN_DIR/claude_api_helpers.sh"
cat > "$HELPERS_FILE" << 'HELPEREOF'
#!/usr/bin/env bash
# _claude_test_api: Send 1-token test to an Anthropic-compatible endpoint
_claude_test_api() {
local url="$1" auth_header="$2" model="${3:-claude-sonnet-4-6}"
local response
response=$(curl -s -w "\n%{http_code}" --max-time 15 "$url" \
-H "$auth_header" \
-H "Content-Type: application/json" \
-H "anthropic-version: 2023-06-01" \
-d "{\"model\":\"$model\",\"max_tokens\":1,\"messages\":[{\"role\":\"user\",\"content\":\"hi\"}]}" \
2>/dev/null || echo "000")
_CLAUDE_TEST_CODE=$(echo "$response" | tail -1)
_CLAUDE_TEST_BODY=$(echo "$response" | sed '$d')
}
# _claude_test_openai_api: Send a tiny test to an OpenAI-compatible endpoint
_claude_test_openai_api() {
local url="$1" api_key="$2" model="${3:-kimi-k2.6}"
local response
response=$(curl -s -w "\n%{http_code}" --max-time 30 "$url" \
-H "Authorization: Bearer $api_key" \
-H "Content-Type: application/json" \
-d "{\"model\":\"$model\",\"max_tokens\":1,\"messages\":[{\"role\":\"user\",\"content\":\"hi\"}]}" \
2>/dev/null || echo "000")
_CLAUDE_TEST_CODE=$(echo "$response" | tail -1)
_CLAUDE_TEST_BODY=$(echo "$response" | sed '$d')
}
_handle_openai_api_response() {
local provider="$1"
local code="$2"
local body="$3"
local topup_url="$4"
local _emsg
case "$code" in
200)
echo -e "\033[0;32mOK\033[0m"
return 0
;;
401|403)
_emsg=$(_claude_extract_error "$body")
echo ""
echo -e "\033[0;31m[ОШИБКА АВТОРИЗАЦИИ]\033[0m Авторизация $provider недействительна (HTTP $code)."
[ -n "$_emsg" ] && echo " $_emsg"
return 401
;;
429)
_emsg=$(_claude_extract_error "$body")
echo ""
echo -e "\033[0;33m[ЛИМИТ ИСЧЕРПАН]\033[0m Баланс/лимит $provider исчерпан."
[ -n "$_emsg" ] && echo " $_emsg"
[ -n "$topup_url" ] && echo " $topup_url"
return 429
;;
000)
echo ""
echo -e "\033[0;33m[СЕТЬ]\033[0m Не удалось проверить ключ (нет сети?). Продолжаю..."
return 0
;;
*)
_emsg=$(_claude_extract_error "$body")
echo ""
echo -e "\033[0;31m[ОШИБКА]\033[0m API $provider вернул HTTP $code."
[ -n "$_emsg" ] && echo " $_emsg"
return 1
;;
esac
}
_claude_extract_error() {
local body="$1"
echo "$body" | python3 -c "
import sys, json
try:
d = json.load(sys.stdin)
e = d.get('error', {})
if isinstance(e, str):
print(e)
else:
msg = e.get('message', '')
if msg:
print(msg)
except:
pass
" 2>/dev/null
}
_claude_offer_reauth() {
local provider="$1"
echo ""
echo -n "Хотите выполнить повторную авторизацию $provider? [Y/n] "
local answer
read -r answer
case "${answer:-Y}" in
[Yy]|[Yy][Ee][Ss]) return 0 ;;
*) echo "Отменено."; return 1 ;;
esac
}
_handle_api_response() {
local provider="$1"
local code="$2"
local body="$3"
local topup_url="$4"
local _emsg
case "$code" in
200)
echo -e "\033[0;32mOK\033[0m"
return 0
;;
401|403)
_emsg=$(_claude_extract_error "$body")
echo ""
echo -e "\033[0;31m[ОШИБКА АВТОРИЗАЦИИ]\033[0m Авторизация $provider недействительна (HTTP $code)."
[ -n "$_emsg" ] && echo " $_emsg"
return 401
;;
429)
_emsg=$(_claude_extract_error "$body")
echo ""
echo -e "\033[0;33m[ЛИМИТ ИСЧЕРПАН]\033[0m Баланс/лимит $provider исчерпан."
[ -n "$_emsg" ] && echo " $_emsg"
[ -n "$topup_url" ] && echo " $topup_url"
return 429
;;
000)
echo ""
echo -e "\033[0;33m[СЕТЬ]\033[0m Не удалось проверить ключ (нет сети?). Продолжаю..."
return 0
;;
400)
_emsg=$(_claude_extract_error "$body")
if echo "${_emsg:-$body}" | grep -qi "RESOURCE_EXHAUSTED"; then
echo ""
echo -e "\033[0;33m[КВОТА ИСЧЕРПАНА]\033[0m Лимит запросов исчерпан."
[ -n "$topup_url" ] && echo " $topup_url"
return 429
fi
# 400 = auth is valid, but max_tokens=1 is too small for thinking models
echo -e "\033[0;32mOK\033[0m"
return 0
;;
*)
_emsg=$(_claude_extract_error "$body")
echo ""
echo -e "\033[0;31m[ОШИБКА]\033[0m API $provider вернул HTTP $code."
[ -n "$_emsg" ] && echo " $_emsg"
return 1
;;
esac
}
_open_browser() {
local url="$1"
if command -v xdg-open &>/dev/null; then xdg-open "$url" 2>/dev/null
elif command -v open &>/dev/null; then open "$url" 2>/dev/null
elif command -v sensible-browser &>/dev/null; then sensible-browser "$url" 2>/dev/null
else echo "Откройте вручную: $url"; fi
}
HELPEREOF
chmod +x "$HELPERS_FILE"
# === claude_gpt ===
cat > "$BIN_DIR/claude_gpt" << 'GPTEOF'
#!/usr/bin/env bash
source ~/.local/bin/claude_api_helpers.sh
proxy_bin="$HOME/.local/bin/claude-code-proxy"
if [ ! -f "$proxy_bin" ]; then
echo "Ошибка: claude-code-proxy не найден. Перезапустите claude_setup.sh"
exit 1
fi
if ! "$proxy_bin" codex auth status &>/dev/null; then
echo ""
echo "Авторизация ChatGPT не найдена."
echo "Сейчас появится ссылка или откроется браузер..."
echo ""
if ! "$proxy_bin" codex auth login 2>&1; then
echo "Если браузер не открылся, попробуйте device flow: claude-code-proxy codex auth device"
exit 1
fi
fi
proxy_pid=""
wrapper_pid=""
effort_proxy="$HOME/.local/bin/claude-gpt-effort-proxy.py"
cleanup() {
[ -n "$proxy_pid" ] && kill "$proxy_pid" 2>/dev/null
[ -n "$wrapper_pid" ] && kill "$wrapper_pid" 2>/dev/null
}
trap cleanup EXIT INT TERM
# Всегда принудительно убираем старый effort-proxy (может висеть после краша)
pkill -f "claude-gpt-effort-proxy" 2>/dev/null
sleep 0.3
# Запускаем claude-code-proxy если не запущен
if ! pgrep -f "claude-code-proxy serve" &>/dev/null; then
PORT=18766 "$proxy_bin" serve &>/tmp/claude-code-proxy.log &
proxy_pid=$!
# curl exit 7 = connection refused; any other response means the port is alive
_j=0; while [ $_j -lt 10 ]; do sleep 1; curl -s --max-time 1 http://localhost:18766/ &>/dev/null; [ "$?" -ne 7 ] && break; _j=$((_j + 1)); done
if [ $_j -ge 10 ]; then
echo -e "\033[0;31m[ОШИБКА]\033[0m claude-code-proxy не запустился за 10 сек."
exit 1
fi
fi
# Запускаем effort-proxy (всегда свежий экземпляр)
python3 "$effort_proxy" 18766 18765 &>/tmp/claude-gpt-effort-proxy.log &
wrapper_pid=$!
_i=0; while [ $_i -lt 10 ]; do sleep 1; curl -s --max-time 1 http://localhost:18765/ &>/dev/null; [ "$?" -ne 7 ] && break; _i=$((_i + 1)); done
if [ $_i -ge 10 ]; then
echo -e "\033[0;31m[ОШИБКА]\033[0m claude-gpt-effort-proxy не запустился за 10 сек."
echo "Лог effort-proxy:"
cat /tmp/claude-gpt-effort-proxy.log 2>/dev/null
exit 1
fi
echo -n "Проверка авторизации ChatGPT... "
_claude_test_api "http://localhost:18765/v1/messages" "x-api-key: dummy" "gpt-5.4-mini"
if [ "$_CLAUDE_TEST_CODE" != "400" ]; then
_handle_api_response "ChatGPT" "$_CLAUDE_TEST_CODE" "$_CLAUDE_TEST_BODY" ""
ret=$?
if [ $ret -eq 401 ]; then
"$proxy_bin" codex auth logout 2>/dev/null
if _claude_offer_reauth "ChatGPT"; then
"$proxy_bin" codex auth login || exit 1
_claude_test_api "http://localhost:18765/v1/messages" "x-api-key: dummy" "gpt-5.4-mini"
[ "$_CLAUDE_TEST_CODE" != "200" ] && [ "$_CLAUDE_TEST_CODE" != "400" ] && { echo "ОШИБКА"; exit 1; }
echo -e "\033[0;32mOK\033[0m"
else
exit 1
fi
elif [ $ret -ne 0 ]; then
exit 1
fi
else
echo -e "\033[0;32mOK\033[0m"
fi
_creds_file="$HOME/.claude/.credentials.json"
_creds_backup=""
[ -f "$_creds_file" ] && _creds_backup=$(cat "$_creds_file")
echo -e "\033[0;33m[ИНФО]\033[0m Режим ChatGPT. Для выхода: Ctrl+C или /exit"
echo -e " Команда \033[1m/logout\033[0m выйдет из Anthropic, а не из ChatGPT."
echo ""
ANTHROPIC_BASE_URL=http://127.0.0.1:18765 \
ANTHROPIC_AUTH_TOKEN=dummy \
ANTHROPIC_MODEL=gpt-5.5 \
ANTHROPIC_DEFAULT_OPUS_MODEL=gpt-5.5 \
ANTHROPIC_DEFAULT_SONNET_MODEL=gpt-5.5 \
ANTHROPIC_DEFAULT_HAIKU_MODEL=gpt-5.4-mini \
CLAUDE_CODE_SUBAGENT_MODEL=gpt-5.4-mini \
CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC=1 \
claude "$@"
if [ -n "$_creds_backup" ] && [ ! -f "$_creds_file" ]; then
mkdir -p "$(dirname "$_creds_file")"
echo "$_creds_backup" > "$_creds_file"
echo -e "\033[0;33m[ИНФО]\033[0m Anthropic credentials восстановлены."
fi
GPTEOF
chmod +x "$BIN_DIR/claude_gpt"
# === claude_deepseek ===
cat > "$BIN_DIR/claude_deepseek" << 'DEEPSEEKEOF'
#!/usr/bin/env bash
source ~/.local/bin/claude_api_helpers.sh
key_file="$HOME/.config/claude-launcher/deepseek_key"
api_key=""
reauth=0
[ -f "$key_file" ] && api_key=$(cat "$key_file")
if [ -n "$api_key" ]; then
echo -n "Проверка сохранённого DeepSeek ключа... "
_claude_test_api "https://api.deepseek.com/anthropic/v1/messages" "x-api-key: $api_key" "deepseek-v4-flash"
_handle_api_response "DeepSeek" "$_CLAUDE_TEST_CODE" "$_CLAUDE_TEST_BODY" "Пополните баланс: https://platform.deepseek.com/top_up"
ret=$?
if [ $ret -eq 401 ]; then
rm -f "$key_file"
api_key=""
reauth=1
elif [ $ret -eq 429 ]; then
echo -n "Продолжить всё равно? (запросы могут не проходить) [y/N] "
read -r _ans; case "${_ans:-N}" in [Yy]*) ;; *) exit 1 ;; esac
elif [ $ret -ne 0 ]; then
exit 1
fi
fi
if [ -z "$api_key" ] && [ "$reauth" -eq 1 ]; then
echo -n "Хотите ввести новый DeepSeek ключ? [Y/n] "
read -r _ans; case "${_ans:-Y}" in [Yy]*) ;; *) exit 1 ;; esac
fi
if [ -z "$api_key" ]; then
echo "Получить ключ: https://platform.deepseek.com/api_keys"
read -r -p "Введите ваш DeepSeek API ключ: " api_key
[ -z "$api_key" ] && { echo "Выход."; exit 1; }
echo -n "Проверяю ключ и баланс... "
_claude_test_api "https://api.deepseek.com/anthropic/v1/messages" "x-api-key: $api_key" "deepseek-v4-flash"
_handle_api_response "DeepSeek" "$_CLAUDE_TEST_CODE" "$_CLAUDE_TEST_BODY" "Пополните баланс: https://platform.deepseek.com/top_up"
ret=$?
if [ $ret -eq 0 ] || [ $ret -eq 429 ]; then
mkdir -p "$(dirname "$key_file")"
echo "$api_key" > "$key_file"
chmod 600 "$key_file"
echo "Ключ сохранён."
if [ $ret -eq 429 ]; then
echo -n "Продолжить всё равно? (запросы могут не проходить) [y/N] "
read -r _ans; case "${_ans:-N}" in [Yy]*) ;; *) exit 1 ;; esac
fi
else
echo "Ключ НЕ сохранён."
exit 1
fi
fi
ANTHROPIC_BASE_URL=https://api.deepseek.com/anthropic \
ANTHROPIC_AUTH_TOKEN="$api_key" \
ANTHROPIC_MODEL=deepseek-v4-pro \
ANTHROPIC_DEFAULT_OPUS_MODEL=deepseek-v4-pro \
ANTHROPIC_DEFAULT_SONNET_MODEL=deepseek-v4-pro \
ANTHROPIC_DEFAULT_HAIKU_MODEL=deepseek-v4-flash \
CLAUDE_CODE_SUBAGENT_MODEL=deepseek-v4-flash \
CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC=1 \
claude "$@"
DEEPSEEKEOF
chmod +x "$BIN_DIR/claude_deepseek"
# === claude_kimi ===
cat > "$BIN_DIR/claude_kimi" << 'KIMIEOF'
#!/usr/bin/env bash
# ============================================================
# claude_kimi — запуск нативного Kimi Code через Artemox API
# ============================================================
kimi_bin="$HOME/.kimi-code/bin/kimi"
if [ ! -f "$kimi_bin" ]; then
echo "Ошибка: Kimi Code не найден ($kimi_bin)."
echo "Установите: npm install -g @moonshot-ai/kimi-code"
exit 1
fi
exec "$kimi_bin" "$@"
KIMIEOF
chmod +x "$BIN_DIR/claude_kimi"
# === claude_gemini ===
cat > "$BIN_DIR/claude_gemini" << 'GEMINIEOF'
#!/usr/bin/env bash
source ~/.local/bin/claude_api_helpers.sh
acc_cmd=""
command -v antigravity-claude-proxy &>/dev/null && acc_cmd="antigravity-claude-proxy"
command -v acc &>/dev/null && acc_cmd="acc"
[ -z "$acc_cmd" ] && { echo "Ошибка: antigravity-claude-proxy не найден."; exit 1; }
proxy_pid=""
cleanup() { [ -n "$proxy_pid" ] && kill "$proxy_pid" 2>/dev/null; }
trap cleanup EXIT INT TERM
if ! curl -sf http://localhost:8080/health &>/dev/null; then
"$acc_cmd" start &>/tmp/antigravity-proxy.log &
proxy_pid=$!
echo "Запускаю Gemini прокси..."
i=0; while [ $i -lt 15 ]; do sleep 1; curl -sf http://localhost:8080/health &>/dev/null && break; i=$((i+1)); done
fi
has_auth=$(curl -sf "http://localhost:8080/account-limits" 2>/dev/null || echo "")
if [ -n "$has_auth" ]; then
total_count=$(echo "$has_auth" | python3 -c "import sys, json; print(len(json.load(sys.stdin).get('accounts', [])))" 2>/dev/null || echo "0")
invalid_count=$(echo "$has_auth" | python3 -c "import sys, json; print(len([a for a in json.load(sys.stdin).get('accounts', []) if a.get('isInvalid')]))" 2>/dev/null || echo "0")
fi
if [ -z "$has_auth" ] || [ "$total_count" = "0" ]; then
echo -e "\033[1;33m⚠ ВНИМАНИЕ: Используйте ОТДЕЛЬНЫЙ Google-аккаунт!\033[0m"
_open_browser "http://localhost:8080"
echo "Нажмите Enter после завершения авторизации в браузере..."
read -r
elif [ "$invalid_count" -gt 0 ]; then
echo -e "\033[0;33m[ПРЕДУПРЕЖДЕНИЕ]\033[0m Обнаружены проблемные аккаунты ($invalid_count из $total_count)."
fi
echo -n "Проверка доступа к Gemini API... "
_claude_test_api "http://localhost:8080/v1/messages" "x-api-key: dummy" "gemini-pro-agent"
if [ "$_CLAUDE_TEST_CODE" != "400" ]; then
_handle_api_response "Gemini" "$_CLAUDE_TEST_CODE" "$_CLAUDE_TEST_BODY" ""
ret=$?
if [ $ret -ne 0 ]; then
if _claude_offer_reauth "Gemini"; then
_open_browser "http://localhost:8080"
echo "Нажмите Enter после авторизации/добавления..."
read -r
_claude_test_api "http://localhost:8080/v1/messages" "x-api-key: dummy" "gemini-pro-agent"
[ "$_CLAUDE_TEST_CODE" != "200" ] && [ "$_CLAUDE_TEST_CODE" != "400" ] && { echo "ОШИБКА"; exit 1; }
echo -e "\033[0;32mOK\033[0m"
else
exit 1
fi
fi
else
_emsg=$(_claude_extract_error "$_CLAUDE_TEST_BODY")
if echo "$_emsg" | grep -q "RESOURCE_EXHAUSTED"; then
echo -e "\033[0;33m[КВОТА ИСЧЕРПАНА]\033[0m Все Gemini аккаунты исчерпали лимит запросов."
exit 1
fi
echo -e "\033[0;32mOK\033[0m"
fi
ANTHROPIC_BASE_URL=http://localhost:8080 \
ANTHROPIC_AUTH_TOKEN=dummy \
ANTHROPIC_MODEL=gemini-pro-agent \
ANTHROPIC_DEFAULT_OPUS_MODEL=gemini-pro-agent \
ANTHROPIC_DEFAULT_SONNET_MODEL=gemini-pro-agent \
ANTHROPIC_DEFAULT_HAIKU_MODEL=gemini-3.5-flash-low \
CLAUDE_CODE_SUBAGENT_MODEL=gemini-3.5-flash-low \
CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC=1 \
claude "$@"
GEMINIEOF
chmod +x "$BIN_DIR/claude_gemini"
success "Скрипты сгенерированы."
# ── 9. Итог ──────────────────────────────────────────────────
echo ""
echo -e "${GREEN}════════════════════════════════════════════════════${NC}"
echo -e "${GREEN} Установка завершена!${NC}"
echo -e "${GREEN}════════════════════════════════════════════════════${NC}"
echo ""
echo "Доступные команды (теперь это независимые скрипты в ~/.local/bin):"
echo -e " ${CYAN}claude_gpt${NC} — GPT-5.5 (ChatGPT Plus/Pro, браузерная авторизация)"
echo -e " ${CYAN}claude_deepseek${NC} — DeepSeek (API ключ сохраняется)"
echo -e " ${CYAN}claude_kimi${NC} — Kimi K2.6 (нативный Kimi Code + Artemox API)"
echo -e " ${CYAN}claude_gemini${NC} — Gemini (Google OAuth через браузер)"
echo ""
echo -e "${YELLOW}⚠️ Для Gemini используйте отдельный Google-аккаунт!${NC}"
echo ""