Compare commits
7 Commits
49
CHANGELOG.md
49
CHANGELOG.md
@@ -1,5 +1,54 @@
|
|||||||
# Changelog
|
# 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)**
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
- **Stricter function_call/output pairing**: Only includes pairs where BOTH call and output exist — no orphan calls sent to Gemini
|
||||||
|
- **Gemini sanitizer rewritten**: Tool messages (`functionCall`/`functionResponse`) are always preserved as-is, never merged or skipped
|
||||||
|
- **Text merging more conservative**: Checks last message for tool content before merging consecutive text messages
|
||||||
|
- **Final trimming safe**: Only removes plain `message` items, never `function_call_output` (which would break tool pairs)
|
||||||
|
- **Merge PR #12**: Fix by qwen-chat coder
|
||||||
|
|
||||||
|
## v3.11.10 (2026-05-26)
|
||||||
|
|
||||||
|
## v3.11.10 (2026-05-26)
|
||||||
|
|
||||||
|
**Antigravity Fix: Interleave function_call/output Pairs, Gemini Turn Trimming (PR #11)**
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
- **Fix Antigravity function_call/output ordering**: Tool calls and their responses are now properly interleaved in sequence (`function_call` → `function_call_output` → `function_call` → ...) instead of being grouped separately
|
||||||
|
- **Gemini sanitizer trimming**: Leading/trailing non-user turns removed for Google API compliance (Google requires conversation to start and end with user turn)
|
||||||
|
- **Stricter role boundary enforcement**: `functionCall` (model) and `functionResponse` (user) never merged across role boundaries
|
||||||
|
- **Merge PR #11**: Fix by qwen-chat coder
|
||||||
|
|
||||||
|
## v3.11.9 (2026-05-26)
|
||||||
|
|
||||||
|
## v3.11.9 (2026-05-26)
|
||||||
|
|
||||||
|
**Antigravity Fix: Preserve functionCall/functionResponse in Gemini Sanitizer (PR #10)**
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
- **Fix Antigravity multi-turn tool use**: The Gemini message sanitizer was incorrectly merging/dropping `functionCall` and `functionResponse` turns, causing Antigravity to think forever without responding. These turns are now always preserved as separate messages.
|
||||||
|
- **Merge PR #10**: `fix: preserve functionCall/functionResponse in Gemini sanitizer` (qwen-chat coder)
|
||||||
|
|
||||||
|
## v3.11.8 (2026-05-26)
|
||||||
|
|
||||||
## v3.11.8 (2026-05-26)
|
## v3.11.8 (2026-05-26)
|
||||||
|
|
||||||
**Vision Cache Persistence, PR #8 Merge**
|
**Vision Cache Persistence, PR #8 Merge**
|
||||||
|
|||||||
BIN
codex-launcher_3.11.12_all.deb
Normal file
BIN
codex-launcher_3.11.12_all.deb
Normal file
Binary file not shown.
Binary file not shown.
@@ -27,6 +27,18 @@ model_catalog_json = ""
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
CHANGELOG = [
|
CHANGELOG = [
|
||||||
|
("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)",
|
||||||
|
"Gemini sanitizer: trim non-user turns for Google API compliance",
|
||||||
|
]),
|
||||||
|
("3.11.9", "2026-05-26", [
|
||||||
|
"Fix Antigravity: preserve functionCall/functionResponse (PR #10)",
|
||||||
|
"Prevents tool responses from being dropped in multi-turn sessions",
|
||||||
|
]),
|
||||||
("3.11.8", "2026-05-26", [
|
("3.11.8", "2026-05-26", [
|
||||||
"Vision cache persisted across requests (PR #8 merge)",
|
"Vision cache persisted across requests (PR #8 merge)",
|
||||||
"No redundant vision API calls for same image URL",
|
"No redundant vision API calls for same image URL",
|
||||||
|
|||||||
@@ -83,6 +83,25 @@ model_catalog_json = ""
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
CHANGELOG = [
|
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", [
|
||||||
|
"Final trimming only removes plain messages, never function_call_output",
|
||||||
|
]),
|
||||||
|
("3.11.10", "2026-05-26", [
|
||||||
|
"Fix Antigravity: interleave function_call/output pairs in correct sequence (PR #11)",
|
||||||
|
"Fix Gemini sanitizer: trim leading/trailing non-user turns for Google API compliance",
|
||||||
|
"Stricter function call/response isolation — no merging across role boundaries",
|
||||||
|
]),
|
||||||
|
("3.11.9", "2026-05-26", [
|
||||||
|
"Fix Antigravity: preserve functionCall/functionResponse in Gemini sanitizer (PR #10)",
|
||||||
|
"Prevents tool responses from being merged/dropped in multi-turn Antigravity sessions",
|
||||||
|
]),
|
||||||
("3.11.8", "2026-05-26", [
|
("3.11.8", "2026-05-26", [
|
||||||
"Vision description cache persisted across requests (no redundant API calls for same image)",
|
"Vision description cache persisted across requests (no redundant API calls for same image)",
|
||||||
"Merge PR #8: fix vision cache persistence across requests",
|
"Merge PR #8: fix vision cache persistence across requests",
|
||||||
|
|||||||
@@ -320,9 +320,10 @@ _active_requests = {}
|
|||||||
_active_requests_lock = threading.Lock()
|
_active_requests_lock = threading.Lock()
|
||||||
|
|
||||||
_pool = uuid.uuid4().hex[:8]
|
_pool = uuid.uuid4().hex[:8]
|
||||||
_antigravity_version = "1.18.3"
|
_antigravity_version = "2.0.1"
|
||||||
_antigravity_version_checked = 0
|
_antigravity_version_checked = 0
|
||||||
_antigravity_version_lock = threading.Lock()
|
_antigravity_version_lock = threading.Lock()
|
||||||
|
_antigravity_version_validated = False
|
||||||
_last_user_urls = collections.deque(maxlen=20)
|
_last_user_urls = collections.deque(maxlen=20)
|
||||||
|
|
||||||
_conn_pool_lock = threading.Lock()
|
_conn_pool_lock = threading.Lock()
|
||||||
@@ -798,49 +799,137 @@ _ANTIGRAVITY_LOOP_TRACKER_LOCK = threading.Lock()
|
|||||||
def _antigravity_loop_key(session_id):
|
def _antigravity_loop_key(session_id):
|
||||||
return f"ag:{session_id}"
|
return f"ag:{session_id}"
|
||||||
|
|
||||||
|
def _validate_antigravity_version(version, access_token=None, project_id=None):
|
||||||
|
if not version or not re.match(r"^\d+\.\d+\.\d+$", version):
|
||||||
|
return False
|
||||||
|
try:
|
||||||
|
if not access_token:
|
||||||
|
access_token = _refresh_oauth_token()
|
||||||
|
if not project_id:
|
||||||
|
token_path = os.path.join(os.path.expanduser("~"), ".cache", "codex-proxy", "google-antigravity-oauth-token.json")
|
||||||
|
try:
|
||||||
|
with open(token_path) as f:
|
||||||
|
project_id = json.load(f).get("project_id", "")
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
if not access_token or not project_id:
|
||||||
|
return True
|
||||||
|
import platform as _plat
|
||||||
|
_os_name = _plat.system().lower()
|
||||||
|
_os_arch = _plat.machine().lower().replace("x86_64", "x64").replace("aarch64", "arm64")
|
||||||
|
ua = f"antigravity/{version} {_os_name}/{_os_arch}"
|
||||||
|
body = {
|
||||||
|
"project": project_id,
|
||||||
|
"model": "gemini-3-flash",
|
||||||
|
"requestType": "agent",
|
||||||
|
"userAgent": ua,
|
||||||
|
"requestId": f"probe-{uuid.uuid4().hex[:8]}",
|
||||||
|
"request": {
|
||||||
|
"contents": [{"role": "user", "parts": [{"text": "hi"}]}],
|
||||||
|
"sessionId": f"probe{int(time.time()*1000)}",
|
||||||
|
"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"},
|
||||||
|
],
|
||||||
|
"generationConfig": {"maxOutputTokens": 32, "stopSequences": ["\n\nHuman:", "[DONE]"]},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
url = "https://daily-cloudcode-pa.googleapis.com/v1internal:streamGenerateContent?alt=sse"
|
||||||
|
headers = {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"Authorization": f"Bearer {access_token}",
|
||||||
|
"User-Agent": ua,
|
||||||
|
}
|
||||||
|
req = urllib.request.Request(url, data=json.dumps(body).encode(), headers=headers)
|
||||||
|
resp = urllib.request.urlopen(req, timeout=15)
|
||||||
|
data = resp.read().decode()
|
||||||
|
if "no longer supported" in data.lower():
|
||||||
|
print(f"[antigravity-version] version {version} rejected (deprecated)", file=sys.stderr)
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
except urllib.error.HTTPError as e:
|
||||||
|
if e.code == 404:
|
||||||
|
print(f"[antigravity-version] version {version} rejected (404)", file=sys.stderr)
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[antigravity-version] probe error for {version}: {e}", file=sys.stderr)
|
||||||
|
return True
|
||||||
|
|
||||||
def _fetch_antigravity_version():
|
def _fetch_antigravity_version():
|
||||||
cache_path = os.path.join(os.path.expanduser("~"), ".cache", "codex-proxy", "antigravity-version.json")
|
cache_path = os.path.join(os.path.expanduser("~"), ".cache", "codex-proxy", "antigravity-version.json")
|
||||||
try:
|
try:
|
||||||
with open(cache_path) as f:
|
with open(cache_path) as f:
|
||||||
cached = json.load(f)
|
cached = json.load(f)
|
||||||
if cached.get("version") and cached.get("checked_at", 0) > time.time() - 6 * 3600:
|
if cached.get("version") and cached.get("validated") and cached.get("checked_at", 0) > time.time() - 6 * 3600:
|
||||||
return cached["version"]
|
return cached["version"]
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
urls = [
|
|
||||||
|
access_token = None
|
||||||
|
project_id = None
|
||||||
|
try:
|
||||||
|
access_token = _refresh_oauth_token()
|
||||||
|
token_path = os.path.join(os.path.expanduser("~"), ".cache", "codex-proxy", "google-antigravity-oauth-token.json")
|
||||||
|
with open(token_path) as f:
|
||||||
|
project_id = json.load(f).get("project_id", "")
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
sources = [
|
||||||
("https://antigravity-auto-updater-974169037036.us-central1.run.app", None),
|
("https://antigravity-auto-updater-974169037036.us-central1.run.app", None),
|
||||||
("https://antigravity.google/changelog", 5000),
|
("https://antigravity.google/changelog", 5000),
|
||||||
]
|
]
|
||||||
for url, limit in urls:
|
|
||||||
|
candidates = []
|
||||||
|
for url, limit in sources:
|
||||||
try:
|
try:
|
||||||
req = urllib.request.Request(url, headers={"User-Agent": "Mozilla/5.0"})
|
req = urllib.request.Request(url, headers={"User-Agent": "Mozilla/5.0"})
|
||||||
resp = urllib.request.urlopen(req, timeout=5)
|
resp = urllib.request.urlopen(req, timeout=5)
|
||||||
text = resp.read().decode(errors="replace")
|
text = resp.read().decode(errors="replace")
|
||||||
if limit:
|
if limit:
|
||||||
text = text[:limit]
|
text = text[:limit]
|
||||||
m = re.search(r"\d+\.\d+\.\d+", text)
|
for m in re.finditer(r"\d+\.\d+\.\d+", text):
|
||||||
if m:
|
ver = m.group(0)
|
||||||
version = m.group(0)
|
if ver not in candidates:
|
||||||
try:
|
candidates.append(ver)
|
||||||
os.makedirs(os.path.dirname(cache_path), exist_ok=True)
|
|
||||||
with open(cache_path, "w", encoding="utf-8") as f:
|
|
||||||
json.dump({"version": version, "checked_at": time.time()}, f)
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
return version
|
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
return _antigravity_version
|
|
||||||
|
for ver in candidates:
|
||||||
|
if _validate_antigravity_version(ver, access_token, project_id):
|
||||||
|
print(f"[antigravity-version] fetched version {ver} validated", file=sys.stderr)
|
||||||
|
try:
|
||||||
|
os.makedirs(os.path.dirname(cache_path), exist_ok=True)
|
||||||
|
with open(cache_path, "w", encoding="utf-8") as f:
|
||||||
|
json.dump({"version": ver, "validated": True, "checked_at": time.time()}, f)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return ver
|
||||||
|
|
||||||
|
fallback = "2.0.1"
|
||||||
|
print(f"[antigravity-version] all candidates failed, using fallback {fallback}", file=sys.stderr)
|
||||||
|
try:
|
||||||
|
os.makedirs(os.path.dirname(cache_path), exist_ok=True)
|
||||||
|
with open(cache_path, "w", encoding="utf-8") as f:
|
||||||
|
json.dump({"version": fallback, "validated": False, "checked_at": time.time()}, f)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return fallback
|
||||||
|
|
||||||
def _ensure_antigravity_version():
|
def _ensure_antigravity_version():
|
||||||
global _antigravity_version, _antigravity_version_checked
|
global _antigravity_version, _antigravity_version_checked, _antigravity_version_validated
|
||||||
if time.time() - _antigravity_version_checked < 6 * 3600:
|
if _antigravity_version_validated and time.time() - _antigravity_version_checked < 6 * 3600:
|
||||||
return _antigravity_version
|
return _antigravity_version
|
||||||
with _antigravity_version_lock:
|
with _antigravity_version_lock:
|
||||||
if time.time() - _antigravity_version_checked < 6 * 3600:
|
if _antigravity_version_validated and time.time() - _antigravity_version_checked < 6 * 3600:
|
||||||
return _antigravity_version
|
return _antigravity_version
|
||||||
_antigravity_version = _fetch_antigravity_version()
|
_antigravity_version = _fetch_antigravity_version()
|
||||||
_antigravity_version_checked = time.time()
|
_antigravity_version_checked = time.time()
|
||||||
|
_antigravity_version_validated = True
|
||||||
return _antigravity_version
|
return _antigravity_version
|
||||||
|
|
||||||
_antigravity_client_version = "1.110.0"
|
_antigravity_client_version = "1.110.0"
|
||||||
@@ -4841,6 +4930,14 @@ def _antigravity_is_simple_user(text):
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
def _antigravity_normalize_context(input_data, model=""):
|
def _antigravity_normalize_context(input_data, model=""):
|
||||||
|
"""
|
||||||
|
Normalize context for Antigravity while PRESERVING function_call -> function_call_output pairs.
|
||||||
|
|
||||||
|
Google's Gemini API requires STRICT alternation:
|
||||||
|
- functionCall (role=model) MUST be immediately followed by functionResponse (role=user)
|
||||||
|
|
||||||
|
This function compacts old history but NEVER breaks tool call/response pairs.
|
||||||
|
"""
|
||||||
if not isinstance(input_data, list) or len(input_data) < 2:
|
if not isinstance(input_data, list) or len(input_data) < 2:
|
||||||
return input_data
|
return input_data
|
||||||
is_claude_model = "claude" in model.lower()
|
is_claude_model = "claude" in model.lower()
|
||||||
@@ -4889,7 +4986,7 @@ def _antigravity_normalize_context(input_data, model=""):
|
|||||||
dev_messages = []
|
dev_messages = []
|
||||||
recent_items = []
|
recent_items = []
|
||||||
tool_outputs = []
|
tool_outputs = []
|
||||||
other_items = []
|
tool_calls = []
|
||||||
|
|
||||||
for i, item in enumerate(input_data):
|
for i, item in enumerate(input_data):
|
||||||
if not isinstance(item, dict):
|
if not isinstance(item, dict):
|
||||||
@@ -4899,8 +4996,8 @@ def _antigravity_normalize_context(input_data, model=""):
|
|||||||
dev_messages.append(item)
|
dev_messages.append(item)
|
||||||
elif t == "function_call_output":
|
elif t == "function_call_output":
|
||||||
tool_outputs.append((i, item))
|
tool_outputs.append((i, item))
|
||||||
elif t in ("function_call",):
|
elif t == "function_call":
|
||||||
other_items.append((i, item))
|
tool_calls.append((i, item))
|
||||||
elif t == "message":
|
elif t == "message":
|
||||||
recent_items.append((i, item))
|
recent_items.append((i, item))
|
||||||
|
|
||||||
@@ -4946,18 +5043,14 @@ def _antigravity_normalize_context(input_data, model=""):
|
|||||||
deduped_tail.append((idx, msg_item))
|
deduped_tail.append((idx, msg_item))
|
||||||
recent_tail = deduped_tail if deduped_tail else recent_tail
|
recent_tail = deduped_tail if deduped_tail else recent_tail
|
||||||
|
|
||||||
tool_call_ids = set()
|
# Build call_id -> function_call mapping
|
||||||
for _, t_item in kept_tools:
|
tool_call_map = {}
|
||||||
cid = t_item.get("call_id", t_item.get("id", ""))
|
for _, call_item in tool_calls:
|
||||||
|
cid = call_item.get("call_id", call_item.get("id", ""))
|
||||||
if cid:
|
if cid:
|
||||||
tool_call_ids.add(cid)
|
tool_call_map[cid] = call_item
|
||||||
|
|
||||||
paired_calls = []
|
|
||||||
for idx, item in other_items:
|
|
||||||
cid = item.get("call_id", item.get("id", ""))
|
|
||||||
if cid in tool_call_ids:
|
|
||||||
paired_calls.append((idx, item))
|
|
||||||
|
|
||||||
|
# Build result: maintain PAIRED sequence (function_call -> function_call_output)
|
||||||
result = list(dev_messages)
|
result = list(dev_messages)
|
||||||
|
|
||||||
compaction_summaries = []
|
compaction_summaries = []
|
||||||
@@ -4973,11 +5066,22 @@ def _antigravity_normalize_context(input_data, model=""):
|
|||||||
summary_text = f"[Tool history summary: {n_summarized} older tool outputs omitted. {n_tool_calls} prior function calls were made for file inspection/editing.]"
|
summary_text = f"[Tool history summary: {n_summarized} older tool outputs omitted. {n_tool_calls} prior function calls were made for file inspection/editing.]"
|
||||||
result.append({"type": "message", "role": "user", "content": [{"type": "input_text", "text": summary_text}]})
|
result.append({"type": "message", "role": "user", "content": [{"type": "input_text", "text": summary_text}]})
|
||||||
|
|
||||||
for _, call_item in paired_calls:
|
# CRITICAL: Add tool CALLS and their corresponding OUTPUTS in PAIRED ORDER
|
||||||
result.append(call_item)
|
# Only include pairs where BOTH call and output are present
|
||||||
|
added_pairs = set()
|
||||||
for _, tool_item in kept_tools:
|
for _, tool_item in kept_tools:
|
||||||
result.append(tool_item)
|
cid = tool_item.get("call_id", tool_item.get("id", ""))
|
||||||
|
if cid and cid in tool_call_map and cid not in added_pairs:
|
||||||
|
# Add function_call FIRST, then function_call_output IMMEDIATELY
|
||||||
|
result.append(tool_call_map[cid])
|
||||||
|
result.append(tool_item)
|
||||||
|
added_pairs.add(cid)
|
||||||
|
|
||||||
|
# Add any orphan tool outputs (no matching call found) - these go at the end before messages
|
||||||
|
for _, tool_item in kept_tools:
|
||||||
|
cid = tool_item.get("call_id", tool_item.get("id", ""))
|
||||||
|
if cid not in added_pairs:
|
||||||
|
result.append(tool_item)
|
||||||
|
|
||||||
for cs_item in compaction_summaries:
|
for cs_item in compaction_summaries:
|
||||||
result.append(cs_item)
|
result.append(cs_item)
|
||||||
@@ -5017,7 +5121,7 @@ def _antigravity_normalize_context(input_data, model=""):
|
|||||||
|
|
||||||
while len(result) > _ANTIGRAVITY_MAX_CONTENTS and total_chars > _ANTIGRAVITY_SOFT_CHARS:
|
while len(result) > _ANTIGRAVITY_MAX_CONTENTS and total_chars > _ANTIGRAVITY_SOFT_CHARS:
|
||||||
for i in range(1, len(result) - 1):
|
for i in range(1, len(result) - 1):
|
||||||
if isinstance(result[i], dict) and result[i].get("type") in ("message", "function_call_output"):
|
if isinstance(result[i], dict) and result[i].get("type") in ("message",):
|
||||||
removed = result.pop(i)
|
removed = result.pop(i)
|
||||||
total_chars -= len(json.dumps(removed, ensure_ascii=False))
|
total_chars -= len(json.dumps(removed, ensure_ascii=False))
|
||||||
break
|
break
|
||||||
@@ -5197,7 +5301,10 @@ class Handler(http.server.BaseHTTPRequestHandler):
|
|||||||
elif BACKEND in ("codebuff", "freebuff"):
|
elif BACKEND in ("codebuff", "freebuff"):
|
||||||
self._handle_codebuff(body, model, stream, tracker)
|
self._handle_codebuff(body, model, stream, tracker)
|
||||||
elif (BACKEND or "").startswith("gemini-oauth"):
|
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:
|
else:
|
||||||
self._handle_openai_compat(body, model, stream, tracker)
|
self._handle_openai_compat(body, model, stream, tracker)
|
||||||
update_snapshot_response(request_id, "completed", time.time() - _req_t0)
|
update_snapshot_response(request_id, "completed", time.time() - _req_t0)
|
||||||
@@ -5386,6 +5493,383 @@ class Handler(http.server.BaseHTTPRequestHandler):
|
|||||||
chat_body["reasoning_effort"] = REASONING_EFFORT
|
chat_body["reasoning_effort"] = REASONING_EFFORT
|
||||||
return chat_body
|
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()
|
||||||
|
ag_identity = "You are Antigravity, a powerful agentic AI coding assistant designed by the Google Deepmind team working on Advanced Agentic Coding.\nYou are pair programming with a USER to solve their coding task. The task may require creating a new codebase, modifying or debugging an existing codebase, or simply answering a question.\n**Absolute paths only**\n**Proactiveness**"
|
||||||
|
system_parts = [{"text": ag_identity}, {"text": "\n--- [SYSTEM_PROMPT_END] ---"}]
|
||||||
|
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"},
|
||||||
|
]}
|
||||||
|
request_body["systemInstruction"] = {"role": "user", "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"}}
|
||||||
|
|
||||||
|
import platform as _plat
|
||||||
|
_os_name = _plat.system().lower()
|
||||||
|
_os_arch = _plat.machine().lower().replace("x86_64", "x64").replace("aarch64", "arm64")
|
||||||
|
_fetched_ver = _ensure_antigravity_version()
|
||||||
|
_ag_ua = f"antigravity/{_fetched_ver} {_os_name}/{_os_arch}"
|
||||||
|
headers = {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"Authorization": f"Bearer {access_token}",
|
||||||
|
"User-Agent": _ag_ua,
|
||||||
|
"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": _ag_ua,
|
||||||
|
"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} ver={_versions[0]}", file=sys.stderr)
|
||||||
|
try:
|
||||||
|
debug_path = os.path.join(_LOG_DIR, f"antigravity-v2-request-{self._session_id}.json")
|
||||||
|
with open(debug_path, "w") as dbg:
|
||||||
|
json.dump(wrapped, dbg, indent=2)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
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)
|
||||||
|
|
||||||
|
_all_404 = True
|
||||||
|
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
|
||||||
|
_all_404 = False
|
||||||
|
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} body={err_body[:300]}", file=sys.stderr)
|
||||||
|
if e.code != 404:
|
||||||
|
_all_404 = False
|
||||||
|
if e.code in (400, 404):
|
||||||
|
try:
|
||||||
|
debug_path = os.path.join(_LOG_DIR, f"antigravity-v2-{e.code}.json")
|
||||||
|
with open(debug_path, "w") as dbg:
|
||||||
|
json.dump({"endpoint": ep, "url": target, "model": model, "wrapped": wrapped, "error": err_body}, dbg, indent=2)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
if e.code == 400:
|
||||||
|
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] and not _all_404:
|
||||||
|
return self.send_json(e.code, {"error": {"type": "upstream_error", "message": _sanitize_err_body(err_body)}})
|
||||||
|
continue
|
||||||
|
except Exception as e:
|
||||||
|
_all_404 = False
|
||||||
|
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 _all_404 and upstream is None:
|
||||||
|
print(f"[{self._session_id}] [antigravity-v2] all endpoints 404, invalidating version cache and re-fetching", file=sys.stderr)
|
||||||
|
global _antigravity_version_validated
|
||||||
|
with _antigravity_version_lock:
|
||||||
|
_antigravity_version_validated = False
|
||||||
|
_antigravity_version_checked = 0
|
||||||
|
_new_ver = _ensure_antigravity_version()
|
||||||
|
if _new_ver != _fetched_ver:
|
||||||
|
print(f"[{self._session_id}] [antigravity-v2] version changed {_fetched_ver} -> {_new_ver}, retrying", file=sys.stderr)
|
||||||
|
_ag_ua_new = f"antigravity/{_new_ver} {_os_name}/{_os_arch}"
|
||||||
|
headers["User-Agent"] = _ag_ua_new
|
||||||
|
wrapped["userAgent"] = _ag_ua_new
|
||||||
|
body_b = json.dumps(wrapped).encode()
|
||||||
|
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()
|
||||||
|
print(f"[{self._session_id}] [antigravity-v2-retry] {ep.replace('https://','')} {e.code}", file=sys.stderr)
|
||||||
|
if e.code == 400:
|
||||||
|
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:
|
||||||
|
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):
|
def _handle_gemini_oauth(self, body, model, stream, tracker=None):
|
||||||
input_data = body.get("input", "")
|
input_data = body.get("input", "")
|
||||||
policy = provider_policy()
|
policy = provider_policy()
|
||||||
@@ -5553,6 +6037,9 @@ class Handler(http.server.BaseHTTPRequestHandler):
|
|||||||
resp_part["functionResponse"]["id"] = call_id
|
resp_part["functionResponse"]["id"] = call_id
|
||||||
contents.append({"role": "user", "parts": [resp_part]})
|
contents.append({"role": "user", "parts": [resp_part]})
|
||||||
|
|
||||||
|
# CRITICAL FIX: Sanitize contents while PRESERVING functionCall -> functionResponse alternation.
|
||||||
|
# Google's Gemini API REQUIRES: functionCall (role=model) must be immediately followed by functionResponse (role=user).
|
||||||
|
# We NEVER merge, skip, or reorder tool-related messages.
|
||||||
if OAUTH_PROVIDER.startswith("google") and "claude" not in model.lower():
|
if OAUTH_PROVIDER.startswith("google") and "claude" not in model.lower():
|
||||||
sanitized = []
|
sanitized = []
|
||||||
last_user_text = None
|
last_user_text = None
|
||||||
@@ -5562,18 +6049,40 @@ class Handler(http.server.BaseHTTPRequestHandler):
|
|||||||
parts = [p for p in content.get("parts", []) if isinstance(p, dict)]
|
parts = [p for p in content.get("parts", []) if isinstance(p, dict)]
|
||||||
if not parts:
|
if not parts:
|
||||||
continue
|
continue
|
||||||
|
# Check if this content has functionCall or functionResponse - these MUST be preserved as-is
|
||||||
|
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()
|
text_key = "\n".join([p.get("text", "") for p in parts if "text" in p]).strip()
|
||||||
|
|
||||||
|
# Tool calls/responses are NEVER merged or skipped - they must maintain strict order
|
||||||
|
if has_function_call or has_function_response:
|
||||||
|
sanitized.append({"role": role, "parts": parts})
|
||||||
|
continue
|
||||||
|
|
||||||
|
# For plain text messages only: skip duplicate consecutive user text
|
||||||
if role == "user" and text_key and text_key == last_user_text:
|
if role == "user" and text_key and text_key == last_user_text:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
|
# Merge consecutive same-role TEXT-ONLY messages (no tool content)
|
||||||
if role == last_role and role in ("user", "model") and sanitized:
|
if role == last_role and role in ("user", "model") and sanitized:
|
||||||
sanitized[-1].setdefault("parts", []).extend(parts)
|
last_parts = sanitized[-1].get("parts", [])
|
||||||
else:
|
# Only merge if the last message is also text-only (no functionCall/functionResponse)
|
||||||
sanitized.append({"role": role, "parts": 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
|
||||||
|
continue
|
||||||
|
|
||||||
|
sanitized.append({"role": role, "parts": parts})
|
||||||
if role == "user" and text_key:
|
if role == "user" and text_key:
|
||||||
last_user_text = text_key
|
last_user_text = text_key
|
||||||
last_role = role
|
last_role = role
|
||||||
|
|
||||||
|
# Trim leading non-user messages (Google expects conversation to start with user)
|
||||||
while sanitized and sanitized[0].get("role") != "user":
|
while sanitized and sanitized[0].get("role") != "user":
|
||||||
sanitized.pop(0)
|
sanitized.pop(0)
|
||||||
|
# Trim trailing non-user messages (must end with user turn for continuation)
|
||||||
while sanitized and sanitized[-1].get("role") != "user":
|
while sanitized and sanitized[-1].get("role") != "user":
|
||||||
sanitized.pop()
|
sanitized.pop()
|
||||||
contents = sanitized
|
contents = sanitized
|
||||||
|
|||||||
Reference in New Issue
Block a user