From 42546b4dc1e4c1d47c10e177f499c4a73c35f105 Mon Sep 17 00:00:00 2001 From: vitaly Date: Sun, 31 May 2026 17:55:57 +0700 Subject: [PATCH] feat: migrate Kimi from Moonshot to Artemox OpenAI API MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- README.md | 2 +- claude_setup.sh | 590 +++++++++++++++++++++++++++++++++++++++----- test_sigint.sh | 10 + tests/test_fixes.sh | 11 + 4 files changed, 548 insertions(+), 65 deletions(-) create mode 100755 test_sigint.sh diff --git a/README.md b/README.md index 0c9851e..3c80b4a 100644 --- a/README.md +++ b/README.md @@ -27,7 +27,7 @@ source ~/.bashrc * `claude_gpt`: Доступ к GPT-5.5 (требует авторизации через `claude-code-proxy`). * `claude_deepseek`: Доступ к DeepSeek (требуется API ключ). -* `claude_kimi`: Доступ к Kimi K2.6 от Moonshot AI (требуется API ключ). +* `claude_kimi`: Доступ к Kimi K2.6 через Artemox OpenAI-compatible API (требуется API ключ). * `claude_gemini`: Доступ к Gemini (требует авторизации через веб-интерфейс `antigravity-claude-proxy`). ## Важные замечания diff --git a/claude_setup.sh b/claude_setup.sh index e7da12a..fd4e345 100755 --- a/claude_setup.sh +++ b/claude_setup.sh @@ -178,6 +178,463 @@ 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 @@ -255,6 +712,60 @@ _claude_test_api() { _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 " @@ -389,7 +900,8 @@ sleep 0.3 if ! pgrep -f "claude-code-proxy serve" &>/dev/null; then PORT=18766 "$proxy_bin" serve &>/tmp/claude-code-proxy.log & proxy_pid=$! - _j=0; while [ $_j -lt 10 ]; do sleep 1; curl -s --max-time 1 http://127.0.0.1:18766/ &>/dev/null; [ "$?" -ne 7 ] && break; _j=$((_j + 1)); done + # 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 @@ -399,7 +911,7 @@ 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://127.0.0.1:18765/ &>/dev/null; [ "$?" -ne 7 ] && break; _i=$((_i + 1)); done +_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:" @@ -408,7 +920,7 @@ if [ $_i -ge 10 ]; then fi echo -n "Проверка авторизации ChatGPT... " -_claude_test_api "http://127.0.0.1:18765/v1/messages" "x-api-key: dummy" "gpt-5.4-mini" +_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" "" @@ -417,7 +929,7 @@ if [ "$_CLAUDE_TEST_CODE" != "400" ]; 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://127.0.0.1:18765/v1/messages" "x-api-key: dummy" "gpt-5.4-mini" + _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 @@ -529,69 +1041,19 @@ chmod +x "$BIN_DIR/claude_deepseek" # === claude_kimi === cat > "$BIN_DIR/claude_kimi" << 'KIMIEOF' #!/usr/bin/env bash -source ~/.local/bin/claude_api_helpers.sh +# ============================================================ +# claude_kimi — запуск нативного Kimi Code через Artemox API +# ============================================================ -key_file="$HOME/.config/claude-launcher/kimi_key" -api_key="" -reauth=0 +kimi_bin="$HOME/.kimi-code/bin/kimi" -[ -f "$key_file" ] && api_key=$(cat "$key_file") - -if [ -n "$api_key" ]; then - echo -n "Проверка сохранённого Kimi ключа... " - _claude_test_api "https://api.moonshot.ai/anthropic/v1/messages" "x-api-key: $api_key" "kimi-k2.6" - _handle_api_response "Kimi" "$_CLAUDE_TEST_CODE" "$_CLAUDE_TEST_BODY" "Пополните баланс: https://platform.moonshot.ai/console/billing" - 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 +if [ ! -f "$kimi_bin" ]; then + echo "Ошибка: Kimi Code не найден ($kimi_bin)." + echo "Установите: npm install -g @moonshot-ai/kimi-code" + exit 1 fi -if [ -z "$api_key" ] && [ "$reauth" -eq 1 ]; then - echo -n "Хотите ввести новый Kimi ключ? [Y/n] " - read -r _ans; case "${_ans:-Y}" in [Yy]*) ;; *) exit 1 ;; esac -fi - -if [ -z "$api_key" ]; then - echo "Получить ключ: https://platform.moonshot.ai/console/api-keys" - read -r -p "Введите ваш Kimi API ключ: " api_key - [ -z "$api_key" ] && { echo "Выход."; exit 1; } - - echo -n "Проверяю ключ и баланс... " - _claude_test_api "https://api.moonshot.ai/anthropic/v1/messages" "x-api-key: $api_key" "kimi-k2.6" - _handle_api_response "Kimi" "$_CLAUDE_TEST_CODE" "$_CLAUDE_TEST_BODY" "Пополните баланс: https://platform.moonshot.ai/console/billing" - 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.moonshot.ai/anthropic \ -ANTHROPIC_AUTH_TOKEN="$api_key" \ -ANTHROPIC_MODEL=kimi-k2.6 \ -ANTHROPIC_DEFAULT_OPUS_MODEL=kimi-k2.6 \ -ANTHROPIC_DEFAULT_SONNET_MODEL=kimi-k2.6 \ -ANTHROPIC_DEFAULT_HAIKU_MODEL=kimi-k2.6 \ -CLAUDE_CODE_SUBAGENT_MODEL=kimi-k2.6 \ -CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC=1 \ -claude "$@" +exec "$kimi_bin" "$@" KIMIEOF chmod +x "$BIN_DIR/claude_kimi" @@ -682,7 +1144,7 @@ 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 (Moonshot AI, 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}" diff --git a/test_sigint.sh b/test_sigint.sh new file mode 100755 index 0000000..a39b55e --- /dev/null +++ b/test_sigint.sh @@ -0,0 +1,10 @@ +#!/bin/bash +python3 -c "import time; print('python started'); time.sleep(100)" & +py_pid=$! +node -e " + console.log('node started'); + process.on('SIGINT', () => console.log('node caught sigint')); + setTimeout(() => console.log('node done'), 10000); +" +echo "checking python" +kill -0 $py_pid 2>/dev/null && echo "python alive" || echo "python dead" diff --git a/tests/test_fixes.sh b/tests/test_fixes.sh index b42835b..bf4c211 100755 --- a/tests/test_fixes.sh +++ b/tests/test_fixes.sh @@ -13,6 +13,7 @@ fail() { echo "[FAIL] $1"; FAIL=$((FAIL+1)); } # Extract sections GPT_SECTION=$(awk '/^cat > "\$BIN_DIR\/claude_gpt"/,/^GPTEOF/' "$SCRIPT") GEMINI_SECTION=$(awk '/^cat > "\$BIN_DIR\/claude_gemini"/,/^GEMINIEOF/' "$SCRIPT") +OPENAI_PROXY_SECTION=$(awk '/^cat > "\$OPENAI_ANTHROPIC_PROXY_BIN"/,/^PYEOF/' "$SCRIPT") # ── Fix 2: trap EXIT kills proxy ────────────────────────────────────────────── test_fix2_trap_exit() { @@ -82,6 +83,15 @@ test_fix7_trap_tmp() { fi } +# ── Kimi/OpenAI proxy: Claude Code sends /v1/messages?beta=true ───────────── +test_kimi_proxy_accepts_query_string() { + if echo "$OPENAI_PROXY_SECTION" | grep -q 'urlparse(self.path).path'; then + ok "Kimi proxy: strips query string before routing /messages" + else + fail "Kimi proxy: does not handle /v1/messages?beta=true" + fi +} + # ── bash syntax of the whole script ───────────────────────────────────────── test_script_syntax() { if bash -n "$SCRIPT" 2>&1; then @@ -99,6 +109,7 @@ test_fix3b_exit7_logic test_fix4_gpt_revalidate test_fix5_gemini_revalidate test_fix7_trap_tmp +test_kimi_proxy_accepts_query_string echo "" echo "Results: $PASS passed, $FAIL failed"