diff --git a/CHANGELOG.md b/CHANGELOG.md index 850edd1..7d79dc5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,18 @@ # Changelog +## v3.11.12 (2026-05-26) + +**New Antigravity v2 Handler (Mimicking anti-api)** + +### New Features +- **Complete rewrite of Antigravity handler** based on https://github.com/ink1ing/anti-api approach +- Safety settings (all OFF), stopSequences, sessionId, requestType: agent +- functionResponse uses `response: { result: string }` format matching anti-api +- Endpoint priority: `daily-cloudcode-pa.googleapis.com` first +- Simplified sanitizer: only deduplicates consecutive user text, never touches tool messages + +## v3.11.11 (2026-05-26) + ## v3.11.11 (2026-05-26) **Antigravity Fix: Stricter function_call/output Pairing + Gemini Sanitizer Rewrite (PR #12)** diff --git a/codex-launcher_3.11.11_all.deb b/codex-launcher_3.11.11_all.deb deleted file mode 100644 index 1815702..0000000 Binary files a/codex-launcher_3.11.11_all.deb and /dev/null differ diff --git a/codex-launcher_3.11.12_all.deb b/codex-launcher_3.11.12_all.deb new file mode 100644 index 0000000..bcbda64 Binary files /dev/null and b/codex-launcher_3.11.12_all.deb differ diff --git a/src/codex-launcher-gui b/src/codex-launcher-gui index 4dd1522..1f61f0d 100755 --- a/src/codex-launcher-gui +++ b/src/codex-launcher-gui @@ -27,9 +27,9 @@ model_catalog_json = "" """ CHANGELOG = [ - ("3.11.11", "2026-05-26", [ - "Fix Antigravity: stricter function_call/output pairing (PR #12)", - "Gemini sanitizer rewritten — tool messages always preserved", + ("3.11.12", "2026-05-26", [ + "New Antigravity v2 handler mimicking anti-api", + "Safety settings, stopSequences, simplified sanitizer", ]), ("3.11.10", "2026-05-26", [ "Fix Antigravity: interleave function_call/output pairs (PR #11)", diff --git a/src/codex_launcher_lib.py b/src/codex_launcher_lib.py index 7aa1ec9..c6c8b84 100644 --- a/src/codex_launcher_lib.py +++ b/src/codex_launcher_lib.py @@ -83,10 +83,14 @@ model_catalog_json = "" """ CHANGELOG = [ + ("3.11.12", "2026-05-26", [ + "New Antigravity v2 handler mimicking anti-api approach", + "Safety settings, stopSequences, sessionId, requestType: agent", + "Simplified sanitizer preserving functionCall/functionResponse", + "Endpoint priority: daily-cloudcode-pa first", + "functionResponse uses response.result (string) format", + ]), ("3.11.11", "2026-05-26", [ - "Fix Antigravity: stricter function_call/output pairing + Gemini sanitizer rewrite (PR #12)", - "Only pairs where BOTH call and output exist are included — no orphan calls", - "Gemini sanitizer: tool messages always preserved as-is, text merging more conservative", "Final trimming only removes plain messages, never function_call_output", ]), ("3.11.10", "2026-05-26", [ diff --git a/src/translate-proxy.py b/src/translate-proxy.py index 68eed68..82624dc 100755 --- a/src/translate-proxy.py +++ b/src/translate-proxy.py @@ -5212,7 +5212,10 @@ class Handler(http.server.BaseHTTPRequestHandler): elif BACKEND in ("codebuff", "freebuff"): self._handle_codebuff(body, model, stream, tracker) elif (BACKEND or "").startswith("gemini-oauth"): - self._handle_gemini_oauth(body, model, stream, tracker) + if OAUTH_PROVIDER == "google-antigravity": + self._handle_antigravity_v2(body, model, stream, tracker) + else: + self._handle_gemini_oauth(body, model, stream, tracker) else: self._handle_openai_compat(body, model, stream, tracker) update_snapshot_response(request_id, "completed", time.time() - _req_t0) @@ -5401,6 +5404,333 @@ class Handler(http.server.BaseHTTPRequestHandler): chat_body["reasoning_effort"] = REASONING_EFFORT return chat_body + def _handle_antigravity_v2(self, body, model, stream, tracker=None): + input_data = body.get("input", "") + _schema = _load_schema(model=model) + if _schema and not _schema.supports_vision: + input_data = _preprocess_vision_input(input_data, _schema) + body = dict(body) + body["input"] = input_data + + if isinstance(input_data, list) and len(input_data) > 30: + input_data = _antigravity_normalize_context(input_data, model) + body = dict(body) + body["input"] = input_data + + access_token = _refresh_oauth_token() + token_path = os.path.join(os.path.expanduser("~"), ".cache", "codex-proxy", "google-antigravity-oauth-token.json") + project_id = "" + try: + with open(token_path) as f: + project_id = json.load(f).get("project_id", "") + except Exception: + pass + + tool_call_names = {} + contents = [] + + if isinstance(input_data, list): + for item in input_data: + t = item.get("type") + if t == "message": + role = "user" if item.get("role") == "user" else "model" + content = item.get("content", "") + parts = [] + if isinstance(content, list): + for c in content: + ct = c.get("type") + if ct in ("input_text", "text"): + parts.append({"text": c.get("text", "")}) + elif ct in ("input_image", "image_url"): + iu = c.get("image_url") or c.get("url", {}) + url = iu.get("url", iu) if isinstance(iu, dict) else iu + if isinstance(url, str) and url.startswith("data:"): + mime, _, b64 = url.partition(";base64,") + mime = mime.replace("data:", "") or "image/png" + parts.append({"inlineData": {"mimeType": mime, "data": b64}}) + else: + parts.append({"text": str(url)}) + elif isinstance(content, str): + parts.append({"text": content}) + if parts: + contents.append({"role": role, "parts": parts}) + elif t == "function_call": + call_id = item.get("call_id") or item.get("id") or f"call_{uuid.uuid4().hex[:24]}" + fname = item.get("name", "") + if call_id and fname: + tool_call_names[call_id] = fname + args = item.get("arguments", "{}") + if isinstance(args, str): + try: + args = json.loads(args) + except Exception: + args = {} + fc_part = {"functionCall": {"name": fname, "args": args, "id": call_id}} + stored_sig = _gemini_get_sig(f"fc:{call_id}") or _gemini_get_sig(f"fc:{fname}") + if stored_sig: + fc_part["thoughtSignature"] = stored_sig + fc_part["thought_signature"] = stored_sig + else: + fc_part["thought_signature"] = "skip_thought_signature_validator" + contents.append({"role": "model", "parts": [fc_part]}) + elif t == "function_call_output": + call_id = item.get("call_id", item.get("id", "")) + output = item.get("output", "") + fname = item.get("name", "") or tool_call_names.get(call_id, "") + resp_part = {"functionResponse": {"name": fname or "unknown", "response": {"result": str(output)}}} + if call_id: + resp_part["functionResponse"]["id"] = call_id + contents.append({"role": "user", "parts": [resp_part]}) + + sanitized = [] + last_user_text = None + last_role = None + for content in contents: + role = content.get("role") + parts = [p for p in content.get("parts", []) if isinstance(p, dict)] + if not parts: + continue + has_function_call = any("functionCall" in p for p in parts) + has_function_response = any("functionResponse" in p for p in parts) + text_key = "\n".join([p.get("text", "") for p in parts if "text" in p]).strip() + + if has_function_call or has_function_response: + sanitized.append({"role": role, "parts": parts}) + last_role = role + continue + + if role == "user" and text_key and text_key == last_user_text: + continue + + if role == last_role and role in ("user", "model") and sanitized: + last_parts = sanitized[-1].get("parts", []) + last_has_tool = any("functionCall" in p or "functionResponse" in p for p in last_parts) + if not last_has_tool: + sanitized[-1].setdefault("parts", []).extend(parts) + if role == "user" and text_key: + last_user_text = text_key + last_role = role + continue + + sanitized.append({"role": role, "parts": parts}) + if role == "user" and text_key: + last_user_text = text_key + last_role = role + + while sanitized and sanitized[0].get("role") != "user": + sanitized.pop(0) + + contents = sanitized + + instructions = body.get("instructions", "").strip() + system_parts = [] + if instructions: + system_parts.append({"text": instructions}) + + gen_config = {"maxOutputTokens": body.get("max_output_tokens", 64000), "stopSequences": ["\n\nHuman:", "[DONE]"]} + if body.get("temperature") is not None: + gen_config["temperature"] = body["temperature"] + if body.get("top_p") is not None: + gen_config["topP"] = body["top_p"] + + _is_claude_model = "claude" in model.lower() + _is_claude_thinking = _is_claude_model and "thinking" in model.lower() + + if REASONING_ENABLED and REASONING_EFFORT != "none": + if _is_claude_thinking: + budget = {"low": 8192, "medium": 16384, "high": 32768}.get(REASONING_EFFORT, 16384) + gen_config["thinkingConfig"] = {"include_thoughts": True, "thinking_budget": budget} + if gen_config.get("maxOutputTokens", 0) <= budget: + gen_config["maxOutputTokens"] = 64000 + elif not _is_claude_model: + budget = {"low": 2048, "medium": 8192, "high": 24576}.get(REASONING_EFFORT, 8192) + gen_config["thinkingConfig"] = {"includeThoughts": True, "thinkingBudget": budget} + + oa_tools = body.get("tools", []) + gemini_tools = [] + if oa_tools: + func_decls = [] + for tool in oa_tools: + ttype = tool.get("type", "function") + fname = tool.get("name", "") + if ttype == "function": + fn = tool.get("function", tool) + name = fn.get("name", fname) + desc = fn.get("description", "") + params = fn.get("parameters", fn.get("input_schema", {})) + func_decls.append({"name": name, "description": desc, "parameters": params}) + elif fname: + func_decls.append({"name": fname, "description": tool.get("description", ""), "parameters": tool.get("parameters", {"type": "object", "properties": {}})}) + if func_decls: + gemini_tools = [{"functionDeclarations": func_decls}] + + contents = _gemini_reattach_sigs(contents) + + ag_key = _antigravity_loop_key(self._session_id) + with _ANTIGRAVITY_LOOP_TRACKER_LOCK: + if ag_key not in _ANTIGRAVITY_LOOP_TRACKER: + _ANTIGRAVITY_LOOP_TRACKER[ag_key] = { + "latest_user_hash": None, "nudge_injected": False, "latest_user_appended": False, + "tool_calls_for_request": 0, "repeated_tool": False, "force_finalize": False, + "last_tool": None, "last_tool_count": 0, + } + ag_state = _ANTIGRAVITY_LOOP_TRACKER[ag_key] + + latest_user = "" + if isinstance(input_data, list): + for item in reversed(input_data): + if item.get("type") == "message" and item.get("role") == "user": + c = item.get("content", "") + if isinstance(c, str): + latest_user = c + elif isinstance(c, list): + latest_user = "\n".join(p.get("text", p.get("input_text", "")) for p in c if isinstance(p, dict)) + break + if latest_user: + latest_norm = " ".join(latest_user.strip().split())[:200] + latest_user_hash = hashlib.sha256(latest_norm.encode()).hexdigest()[:16] + if latest_user_hash != ag_state.get("latest_user_hash"): + ag_state["latest_user_hash"] = latest_user_hash + ag_state["nudge_injected"] = False + ag_state["latest_user_appended"] = False + ag_state["tool_calls_for_request"] = 0 + ag_state["repeated_tool"] = False + ag_state["force_finalize"] = False + ag_state["last_tool"] = None + ag_state["last_tool_count"] = 0 + n_tool_calls = sum(1 for it in input_data if isinstance(it, dict) and it.get("type") == "function_call") + ag_state["tool_calls_for_request"] = n_tool_calls + last_tool_key = None + for item in reversed(input_data): + if isinstance(item, dict) and item.get("type") == "function_call": + fname = item.get("name", "") + args_str = json.dumps(item.get("arguments", {}), sort_keys=True)[:100] + last_tool_key = f"{fname}:{args_str}" + break + if last_tool_key: + if last_tool_key == ag_state.get("last_tool"): + ag_state["last_tool_count"] = ag_state.get("last_tool_count", 0) + 1 + if ag_state["last_tool_count"] >= 5: + ag_state["repeated_tool"] = True + ag_state["force_finalize"] = True + else: + ag_state["last_tool"] = last_tool_key + ag_state["last_tool_count"] = 1 + + if ag_state.get("force_finalize"): + contents.append({"role": "user", "parts": [{"text": "STOP CALLING TOOLS. APPLY THE FINAL EDIT OR SUMMARIZE WHAT BLOCKED YOU. DO NOT CALL ANY MORE TOOLS."}]}) + + if not _antigravity_is_simple_user(latest_user): + contents.insert(0, {"role": "user", "parts": [{"text": _GEMINI_AGENT_GUARDRAIL}]}) + + request_body = {"contents": contents, "safetySettings": [ + {"category": "HARM_CATEGORY_HARASSMENT", "threshold": "OFF"}, + {"category": "HARM_CATEGORY_HATE_SPEECH", "threshold": "OFF"}, + {"category": "HARM_CATEGORY_SEXUALLY_EXPLICIT", "threshold": "OFF"}, + {"category": "HARM_CATEGORY_DANGEROUS_CONTENT", "threshold": "OFF"}, + {"category": "HARM_CATEGORY_CIVIC_INTEGRITY", "threshold": "OFF"}, + ]} + if system_parts: + request_body["systemInstruction"] = {"parts": system_parts} + if gen_config: + request_body["generationConfig"] = gen_config + if gemini_tools: + request_body["tools"] = gemini_tools + if _is_claude_model and gemini_tools: + request_body["toolConfig"] = {"functionCallingConfig": {"mode": "VALIDATED"}} + + version = _ensure_antigravity_version() + import platform as _plat + _os_name = _plat.system().lower() + _os_arch = _plat.machine().lower().replace("x86_64", "x64").replace("aarch64", "arm64") + headers = { + "Content-Type": "application/json", + "Authorization": f"Bearer {access_token}", + "User-Agent": f"antigravity/{version} {_os_name}/{_os_arch}", + "X-Client-Name": "antigravity", + "X-Client-Version": _ensure_antigravity_client_version(), + "x-goog-api-client": "gl-node/18.18.2 fire/0.8.6 grpc/1.10.x", + } + + wrapped = { + "project": project_id, + "model": model, + "requestType": "agent", + "userAgent": f"antigravity/{version} {_os_name}/{_os_arch}", + "requestId": f"agent-{uuid.uuid4().hex[:12]}", + "request": request_body, + } + wrapped["request"]["sessionId"] = f"{uuid.uuid4().hex}{int(time.time()*1000)}" + + _allow_staging = os.environ.get("ALLOW_ANTIGRAVITY_STAGING", "0") == "1" + _antigravity_endpoints = [ + "https://daily-cloudcode-pa.googleapis.com", + "https://daily-cloudcode-pa.sandbox.googleapis.com", + "https://cloudcode-pa.googleapis.com", + ] + if _allow_staging: + _antigravity_endpoints.append("https://autopush-cloudcode-pa.sandbox.googleapis.com") + + body_b = json.dumps(wrapped).encode() + print(f"[{self._session_id}] [antigravity-v2] model={model} stream={stream} contents={len(contents)} tools={bool(gemini_tools)} project={project_id}", file=sys.stderr) + + upstream = None + chosen_ep = None + global _antigravity_preferred_endpoint + with _antigravity_endpoint_lock: + _pref = _antigravity_preferred_endpoint + ordered = ([_pref] + [e for e in _antigravity_endpoints if e != _pref]) if _pref and _pref in _antigravity_endpoints else list(_antigravity_endpoints) + + for ep in ordered: + action = "streamGenerateContent" if stream else "generateContent" + url_suffix = f"v1internal:{action}?alt=sse" if stream else f"v1internal:{action}" + target = f"{ep}/{url_suffix}" + req = urllib.request.Request(target, data=body_b, headers=headers) + try: + upstream = urllib.request.urlopen(req, timeout=_upstream_timeout(body, stream)) + chosen_ep = ep + with _antigravity_endpoint_lock: + _antigravity_preferred_endpoint = ep + break + except urllib.error.HTTPError as e: + err_body = e.read().decode() + err_class = _classify_antigravity_error(e.code, err_body) + print(f"[{self._session_id}] [antigravity-v2] {ep.replace('https://','')} {e.code} class={err_class}", file=sys.stderr) + if e.code == 400: + try: + debug_path = os.path.join(_LOG_DIR, "antigravity-v2-400.json") + with open(debug_path, "w") as dbg: + json.dump({"endpoint": ep, "model": model, "wrapped": wrapped, "error": err_body}, dbg, indent=2) + except Exception: + pass + return self.send_json(e.code, {"error": {"type": "upstream_error", "message": _sanitize_err_body(err_body)}}) + if err_class in ("auth_permanent", "service_disabled", "forbidden", "account_banned", "validation_required"): + return self.send_json(e.code, {"error": {"type": "upstream_error", "message": _sanitize_err_body(err_body)}}) + if err_class in ("quota_exhausted", "rate_limited"): + pool = _google_antigravity_pool + _, acct = _get_google_account(OAUTH_PROVIDER) + if acct: + reset_s = _parse_rate_limit_reset(err_body) + cooldown = reset_s if reset_s and reset_s > 10 else 60 + pool.mark_rate_limited(acct, cooldown) + return self.send_json(e.code, {"error": {"type": "upstream_error", "message": _sanitize_err_body(err_body)}}) + if ep == ordered[-1]: + return self.send_json(e.code, {"error": {"type": "upstream_error", "message": _sanitize_err_body(err_body)}}) + continue + except Exception as e: + print(f"[{self._session_id}] [antigravity-v2] {ep.replace('https://','')} conn failed: {e}", file=sys.stderr) + if ep == ordered[-1]: + return self.send_json(502, {"error": {"type": "proxy_error", "message": str(e)}}) + continue + + if upstream is None: + return self.send_json(502, {"error": {"type": "proxy_error", "message": "All endpoints failed"}}) + + if stream: + self._forward_gemini_sse(upstream, model, body, input_data, tracker) + else: + self._forward_gemini_json(upstream, model, body, input_data) + def _handle_gemini_oauth(self, body, model, stream, tracker=None): input_data = body.get("input", "") policy = provider_policy()