diff --git a/front/antigravity_credit_summary_preview.html b/front/antigravity_credit_summary_preview.html new file mode 100644 index 000000000..8b11ef5b0 --- /dev/null +++ b/front/antigravity_credit_summary_preview.html @@ -0,0 +1,385 @@ + + + + + + + Antigravity 总额度 UI 预览 + + + + +
+

Antigravity 总额度 UI 预览

+ + +
+ + + + + diff --git a/front/common.js b/front/common.js index 12c75df94..2725de592 100644 --- a/front/common.js +++ b/front/common.js @@ -37,7 +37,12 @@ const AppState = { usageStatsData: {}, // 冷却倒计时 - cooldownTimerInterval: null + cooldownTimerInterval: null, + + // Antigravity total quota UI + antigravityCreditSummaryTimer: null, + antigravityCreditSummaryLoaded: false, + antigravityCreditSummaryLoading: false }; // ===================================================================== @@ -1035,7 +1040,10 @@ function switchTab(tabName) { // 标签页数据加载(从动画中分离出来) function triggerTabDataLoad(tabName) { if (tabName === 'manage') AppState.creds.refresh(); - if (tabName === 'antigravity-manage') AppState.antigravityCreds.refresh(); + if (tabName === 'antigravity-manage') { + AppState.antigravityCreds.refresh(); + startAntigravityCreditSummaryAutoRefresh(true); + } if (tabName === 'config') loadConfig(); if (tabName === 'logs') connectWebSocket(); } @@ -1455,7 +1463,215 @@ async function downloadAllCreds() { } // Antigravity凭证管理 -function refreshAntigravityCredsList() { AppState.antigravityCreds.refresh(); } +function refreshAntigravityCredsList() { + AppState.antigravityCreds.refresh(); + startAntigravityCreditSummaryAutoRefresh(false); + refreshAntigravityCreditSummary(true); +} + +function getAntigravityCreditSummaryElement(id) { + return document.getElementById(id); +} + +function setAntigravityCreditSummaryText(id, text) { + const element = getAntigravityCreditSummaryElement(id); + if (element) element.textContent = text; +} + +function formatAntigravityCreditNumber(value) { + if (value === null || value === undefined || value === '' || Number.isNaN(Number(value))) { + return '--'; + } + + return Number(value).toLocaleString('zh-CN', { + maximumFractionDigits: 2 + }); +} + +function formatAntigravityCreditPercent(value) { + if (value === null || value === undefined || value === '' || Number.isNaN(Number(value))) { + return '--%'; + } + + return `${Number(value).toLocaleString('zh-CN', { + maximumFractionDigits: 2 + })}%`; +} + +function formatAntigravitySummaryTime(timestamp) { + if (!timestamp) return '刚刚'; + + return new Date(timestamp * 1000).toLocaleTimeString('zh-CN', { + hour12: false, + hour: '2-digit', + minute: '2-digit', + second: '2-digit' + }); +} + +function getAntigravityQuotaColor(percent) { + const safePercent = Number(percent); + if (safePercent >= 50) return '#28a745'; + if (safePercent >= 20) return '#ffc107'; + return '#dc3545'; +} + +function renderAntigravityCreditModelList(modelSummaries) { + const container = getAntigravityCreditSummaryElement('antigravityCreditModelList'); + if (!container) return; + + if (!Array.isArray(modelSummaries) || modelSummaries.length === 0) { + container.innerHTML = '
暂无分模型额度数据
'; + return; + } + + container.innerHTML = modelSummaries.map(modelInfo => { + const percent = modelInfo.remaining_percent; + const safePercent = percent === null || percent === undefined + ? 0 + : Math.max(0, Math.min(100, Number(percent))); + const percentText = formatAntigravityCreditPercent(percent); + const accountCount = modelInfo.account_count || 0; + const exhaustedAccounts = modelInfo.exhausted_accounts || 0; + const color = getAntigravityQuotaColor(safePercent); + const modelName = escapeHtml(modelInfo.model || 'unknown'); + + return ` +
+
+ ${modelName} + ${percentText} +
+
+
+
+
覆盖 ${accountCount} 个启用号 · ${exhaustedAccounts} 个已耗尽
+
+ `; + }).join(''); +} + +function updateAntigravityCreditSummaryUI(data) { + const creditsText = formatAntigravityCreditNumber(data.total_remaining_credits); + const enabledAccounts = data.enabled_accounts || 0; + const creditAccounts = data.credit_accounts || 0; + const quotaAccounts = data.quota_accounts || 0; + const failedAccounts = data.failed_accounts || 0; + + setAntigravityCreditSummaryText('antigravityCreditRemainingAmount', creditsText); + setAntigravityCreditSummaryText('antigravityCreditEnabledAccounts', enabledAccounts.toString()); + + if (enabledAccounts > 0) { + setAntigravityCreditSummaryText( + 'antigravityCreditSummaryCompact', + `${creditsText} 积分 · 启用 ${enabledAccounts} 个` + ); + } else { + setAntigravityCreditSummaryText('antigravityCreditSummaryCompact', '没有启用凭证'); + } + + renderAntigravityCreditModelList(data.model_summaries || []); + + const refreshTime = formatAntigravitySummaryTime(data.updated_at); + const metaParts = [ + `启用 ${enabledAccounts} 个`, + `积分数据 ${creditAccounts} 个`, + `额度数据 ${quotaAccounts} 个`, + `刷新 ${refreshTime}` + ]; + if (failedAccounts > 0) metaParts.push(`失败 ${failedAccounts} 个`); + setAntigravityCreditSummaryText('antigravityCreditSummaryMeta', metaParts.join(' · ')); + + const meta = getAntigravityCreditSummaryElement('antigravityCreditSummaryMeta'); + if (meta && Array.isArray(data.errors) && data.errors.length > 0) { + meta.title = data.errors.map(item => `${item.filename}: ${item.error}`).join('\n'); + } else if (meta) { + meta.removeAttribute('title'); + } + + AppState.antigravityCreditSummaryLoaded = true; +} + +function setAntigravityCreditSummaryLoading(isLoading) { + AppState.antigravityCreditSummaryLoading = isLoading; + const refreshBtn = getAntigravityCreditSummaryElement('antigravityCreditSummaryRefreshBtn'); + if (refreshBtn) { + refreshBtn.disabled = isLoading; + refreshBtn.textContent = isLoading ? '刷新中...' : '手动刷新'; + } + + if (isLoading && !AppState.antigravityCreditSummaryLoaded) { + setAntigravityCreditSummaryText('antigravityCreditSummaryCompact', '正在获取...'); + setAntigravityCreditSummaryText('antigravityCreditSummaryMeta', '正在获取启用凭证额度...'); + } +} + +function toggleAntigravityCreditSummary() { + const body = getAntigravityCreditSummaryElement('antigravityCreditSummaryBody'); + const card = getAntigravityCreditSummaryElement('antigravityCreditSummaryCard'); + if (!body || !card) return; + + const shouldOpen = body.style.display !== 'block'; + body.style.display = shouldOpen ? 'block' : 'none'; + card.classList.toggle('expanded', shouldOpen); + card.setAttribute('aria-expanded', shouldOpen ? 'true' : 'false'); + setAntigravityCreditSummaryText('antigravityCreditSummaryToggleIcon', shouldOpen ? '收起' : '展开'); + + if (shouldOpen && !AppState.antigravityCreditSummaryLoaded) { + refreshAntigravityCreditSummary(false); + } +} + +function startAntigravityCreditSummaryAutoRefresh(fetchNow = false) { + const card = getAntigravityCreditSummaryElement('antigravityCreditSummaryCard'); + if (!card) return; + + if (!AppState.antigravityCreditSummaryTimer) { + AppState.antigravityCreditSummaryTimer = setInterval(() => { + refreshAntigravityCreditSummary(true); + }, 10 * 60 * 1000); + } + + if (fetchNow) { + refreshAntigravityCreditSummary(true); + } +} + +async function refreshAntigravityCreditSummary(silent = false) { + const card = getAntigravityCreditSummaryElement('antigravityCreditSummaryCard'); + if (!card || AppState.antigravityCreditSummaryLoading) return; + + setAntigravityCreditSummaryLoading(true); + + try { + const response = await fetch('./creds/antigravity-credit-summary', { + method: 'GET', + headers: getAuthHeaders() + }); + const data = await response.json(); + + if (response.ok && data.success) { + updateAntigravityCreditSummaryUI(data); + if (!silent) showStatus('Antigravity 总额度已刷新', 'success'); + } else { + const errorMsg = data.error || data.detail || '获取总额度失败'; + setAntigravityCreditSummaryText('antigravityCreditSummaryCompact', '获取失败'); + setAntigravityCreditSummaryText('antigravityCreditSummaryMeta', errorMsg); + if (!silent) showStatus(errorMsg, 'error'); + } + } catch (error) { + setAntigravityCreditSummaryText('antigravityCreditSummaryCompact', '获取失败'); + setAntigravityCreditSummaryText('antigravityCreditSummaryMeta', error.message); + if (!silent) showStatus(`获取总额度失败: ${error.message}`, 'error'); + } finally { + setAntigravityCreditSummaryLoading(false); + } +} + +window.toggleAntigravityCreditSummary = toggleAntigravityCreditSummary; +window.refreshAntigravityCreditSummary = refreshAntigravityCreditSummary; +window.startAntigravityCreditSummaryAutoRefresh = startAntigravityCreditSummaryAutoRefresh; + function applyAntigravityStatusFilter() { AppState.antigravityCreds.applyStatusFilter(); } function changeAntigravityPage(direction) { AppState.antigravityCreds.changePage(direction); } function changeAntigravityPageSize() { AppState.antigravityCreds.changePageSize(); } diff --git a/front/control_panel.html b/front/control_panel.html index 11bc986dd..3b1f96e08 100644 --- a/front/control_panel.html +++ b/front/control_panel.html @@ -638,6 +638,190 @@ box-shadow: 0 2px 8px rgba(23, 162, 184, 0.15); } + .quota-summary-card { + background: #ffffff; + border: 1px solid #dce3ea; + border-left: 4px solid #17a2b8; + border-radius: 8px; + margin: 0 0 20px 0; + overflow: hidden; + box-shadow: 0 1px 4px rgba(0, 0, 0, 0.08); + } + + .quota-summary-header { + width: 100%; + border: 0; + background: #f8fbfd; + color: #333; + cursor: pointer; + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + padding: 12px 16px; + text-align: left; + } + + .quota-summary-title { + display: block; + font-size: 15px; + font-weight: 700; + } + + .quota-summary-subtitle { + display: block; + color: #667085; + font-size: 12px; + margin-top: 3px; + } + + .quota-summary-compact { + color: #0c5460; + font-size: 13px; + font-weight: 700; + white-space: nowrap; + } + + .quota-summary-toggle { + color: #666; + font-size: 12px; + margin-left: 8px; + white-space: nowrap; + } + + .quota-summary-body { + border-top: 1px solid #edf0f3; + padding: 14px 16px 16px; + } + + .quota-summary-metrics { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 10px; + margin-bottom: 12px; + } + + .quota-summary-metric { + background: #f8f9fa; + border-radius: 6px; + padding: 10px; + } + + .quota-summary-metric strong { + color: #222; + display: block; + font-size: 22px; + line-height: 1.2; + word-break: break-word; + } + + .quota-summary-metric span { + color: #667085; + display: block; + font-size: 12px; + margin-top: 4px; + } + + .quota-model-list { + display: grid; + gap: 8px; + margin-top: 14px; + } + + .quota-model-item { + background: #ffffff; + border: 1px solid #edf0f3; + border-radius: 6px; + padding: 10px; + } + + .quota-model-row { + align-items: center; + display: flex; + gap: 10px; + justify-content: space-between; + } + + .quota-model-name { + color: #333; + font-size: 13px; + font-weight: 700; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + .quota-model-percent { + font-size: 13px; + font-weight: 700; + white-space: nowrap; + } + + .quota-model-bar { + background: #e9ecef; + border-radius: 999px; + height: 7px; + margin: 8px 0 6px; + overflow: hidden; + } + + .quota-model-fill { + height: 100%; + transition: width 0.25s ease; + width: 0; + } + + .quota-model-meta, + .quota-model-empty { + color: #667085; + font-size: 12px; + } + + .quota-summary-footer { + align-items: center; + display: flex; + gap: 10px; + justify-content: space-between; + margin-top: 10px; + } + + .quota-summary-meta { + color: #667085; + font-size: 12px; + } + + .quota-summary-refresh { + background: #17a2b8; + border: 0; + border-radius: 4px; + color: white; + cursor: pointer; + font-size: 13px; + padding: 7px 10px; + white-space: nowrap; + } + + .quota-summary-refresh:disabled { + background: #9ab7c0; + cursor: not-allowed; + } + + @media (max-width: 640px) { + .quota-summary-header, + .quota-summary-footer { + align-items: flex-start; + flex-direction: column; + } + + .quota-summary-compact { + white-space: normal; + } + + .quota-summary-metrics { + grid-template-columns: 1fr; + } + } + .manage-actions { margin-bottom: 20px; display: flex; @@ -1870,6 +2054,40 @@

Antigravity凭证文件管理

+ + +
@@ -2393,8 +2611,8 @@

📞 联系我们

- + - \ No newline at end of file + diff --git a/front/control_panel_mobile.html b/front/control_panel_mobile.html index ec18ea0d8..1b42f25ef 100644 --- a/front/control_panel_mobile.html +++ b/front/control_panel_mobile.html @@ -767,6 +767,175 @@ } /* 批量操作按钮禁用状态 */ + .quota-summary-card { + background: #ffffff; + border: 1px solid #dce3ea; + border-left: 4px solid #17a2b8; + border-radius: 8px; + margin: 0 0 15px 0; + overflow: hidden; + box-shadow: 0 1px 4px rgba(0, 0, 0, 0.08); + } + + .quota-summary-header { + width: 100%; + border: 0; + background: #f8fbfd; + color: #333; + cursor: pointer; + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 10px; + padding: 12px; + text-align: left; + } + + .quota-summary-title { + display: block; + font-size: 15px; + font-weight: 700; + } + + .quota-summary-subtitle { + display: block; + color: #667085; + font-size: 12px; + margin-top: 3px; + } + + .quota-summary-compact { + color: #0c5460; + display: block; + font-size: 12px; + font-weight: 700; + margin-bottom: 4px; + text-align: right; + } + + .quota-summary-toggle { + color: #666; + display: block; + font-size: 12px; + text-align: right; + } + + .quota-summary-body { + border-top: 1px solid #edf0f3; + padding: 12px; + } + + .quota-summary-metrics { + display: grid; + grid-template-columns: 1fr; + gap: 8px; + margin-bottom: 10px; + } + + .quota-summary-metric { + background: #f8f9fa; + border-radius: 6px; + padding: 10px; + } + + .quota-summary-metric strong { + color: #222; + display: block; + font-size: 20px; + line-height: 1.2; + word-break: break-word; + } + + .quota-summary-metric span { + color: #667085; + display: block; + font-size: 12px; + margin-top: 4px; + } + + .quota-model-list { + display: grid; + gap: 8px; + margin-top: 12px; + } + + .quota-model-item { + background: #ffffff; + border: 1px solid #edf0f3; + border-radius: 6px; + padding: 10px; + } + + .quota-model-row { + align-items: center; + display: flex; + gap: 8px; + justify-content: space-between; + } + + .quota-model-name { + color: #333; + font-size: 13px; + font-weight: 700; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + .quota-model-percent { + font-size: 13px; + font-weight: 700; + white-space: nowrap; + } + + .quota-model-bar { + background: #e9ecef; + border-radius: 999px; + height: 7px; + margin: 8px 0 6px; + overflow: hidden; + } + + .quota-model-fill { + height: 100%; + transition: width 0.25s ease; + width: 0; + } + + .quota-model-meta, + .quota-model-empty { + color: #667085; + font-size: 12px; + } + + .quota-summary-footer { + display: flex; + flex-direction: column; + gap: 8px; + margin-top: 10px; + } + + .quota-summary-meta { + color: #667085; + font-size: 12px; + } + + .quota-summary-refresh { + background: #17a2b8; + border: 0; + border-radius: 4px; + color: white; + cursor: pointer; + font-size: 13px; + padding: 8px 10px; + width: 100%; + } + + .quota-summary-refresh:disabled { + background: #9ab7c0; + cursor: not-allowed; + } + .btn-small:disabled { background-color: #e9ecef !important; color: #6c757d !important; @@ -1562,6 +1731,40 @@

Antigravity凭证文件管理

+ + +
@@ -2119,7 +2322,7 @@

📞 联系我们

- + - \ No newline at end of file + diff --git a/src/api/antigravity.py b/src/api/antigravity.py index dad8613ad..08cb813cf 100644 --- a/src/api/antigravity.py +++ b/src/api/antigravity.py @@ -18,9 +18,15 @@ ) from log import log +from src.api.empty_output import ( + build_empty_model_output_response, + is_empty_model_output, + stream_chunk_has_visible_output, +) from src.credential_manager import credential_manager from src.httpx_client import stream_post_async, post_async from src.models import Model, model_to_dict +from src.session_affinity import extract_cache_session_key from src.utils import ANTIGRAVITY_USER_AGENT # 导入共同的基础功能 @@ -262,10 +268,11 @@ async def stream_request( Response对象(错误时)或 bytes流/str流(成功时) """ model_name = body.get("model", "") + session_key = extract_cache_session_key(body, headers) # 1. 获取有效凭证 cred_result = await credential_manager.get_valid_credential( - mode="antigravity", model_name=model_name + mode="antigravity", model_name=model_name, session_key=session_key ) if not cred_result: @@ -331,7 +338,8 @@ def apply_enabled_credit_types(cred_data: Dict[str, Any]) -> None: async def refresh_credential_fast(): nonlocal current_file, access_token, auth_headers, project_id, final_payload cred_result = await credential_manager.get_valid_credential( - mode="antigravity", model_name=model_name + mode="antigravity", model_name=model_name, session_key=session_key, + exclude_credential=current_file ) if not cred_result: return None @@ -361,6 +369,7 @@ def apply_cred_result(cred_result: Tuple[str, Dict[str, Any]]) -> bool: for attempt in range(max_retries + 1): success_recorded = False # 标记是否已记录成功 need_retry = False # 标记是否需要重试 + buffered_chunks = [] try: async for chunk in stream_post_async( @@ -397,7 +406,8 @@ def apply_cred_result(cred_result: Tuple[str, Dict[str, Any]]) -> bool: if next_cred_task is None and attempt < max_retries: next_cred_task = asyncio.create_task( credential_manager.get_valid_credential( - mode="antigravity", model_name=model_name + mode="antigravity", model_name=model_name, + session_key=session_key, exclude_credential=current_file ) ) @@ -435,21 +445,29 @@ def apply_cred_result(cred_result: Tuple[str, Dict[str, Any]]) -> bool: return else: # 不是Response,说明是真流,直接yield返回 + # 记录原始chunk内容(用于调试) + if isinstance(chunk, bytes): + log.debug(f"[ANTIGRAVITY STREAM RAW] chunk(bytes): {chunk}") + else: + log.debug(f"[ANTIGRAVITY STREAM RAW] chunk(str): {chunk}") + # 只在第一个chunk时记录成功 if not success_recorded: + buffered_chunks.append(chunk) + if not stream_chunk_has_visible_output(chunk): + continue + await record_api_call_success( credential_manager, current_file, mode="antigravity", model_name=model_name ) success_recorded = True log.debug(f"[ANTIGRAVITY STREAM] 开始接收流式响应,模型: {model_name}") - # 记录原始chunk内容(用于调试) - if isinstance(chunk, bytes): - log.debug(f"[ANTIGRAVITY STREAM RAW] chunk(bytes): {chunk}") + for buffered_chunk in buffered_chunks: + yield buffered_chunk + buffered_chunks = [] else: - log.debug(f"[ANTIGRAVITY STREAM RAW] chunk(str): {chunk}") - - yield chunk + yield chunk # 流式请求完成,检查结果 if success_recorded: @@ -468,11 +486,7 @@ def apply_cred_result(cred_result: Tuple[str, Dict[str, Any]]) -> bool: need_retry = True else: log.error(f"[ANTIGRAVITY STREAM] 空回复达到最大重试次数") - yield Response( - content=json.dumps({"error": "服务返回空回复"}), - status_code=500, - media_type="application/json" - ) + yield build_empty_model_output_response() return # 统一处理重试 @@ -558,10 +572,11 @@ async def non_stream_request( log.debug("[ANTIGRAVITY] 使用传统非流式模式") model_name = body.get("model", "") + session_key = extract_cache_session_key(body, headers) # 1. 获取有效凭证 cred_result = await credential_manager.get_valid_credential( - mode="antigravity", model_name=model_name + mode="antigravity", model_name=model_name, session_key=session_key ) if not cred_result: @@ -625,7 +640,8 @@ def apply_enabled_credit_types(cred_data: Dict[str, Any]) -> None: async def refresh_credential_fast(): nonlocal current_file, access_token, auth_headers, project_id, final_payload cred_result = await credential_manager.get_valid_credential( - mode="antigravity", model_name=model_name + mode="antigravity", model_name=model_name, session_key=session_key, + exclude_credential=current_file ) if not cred_result: return None @@ -667,6 +683,15 @@ def apply_cred_result(cred_result: Tuple[str, Dict[str, Any]]) -> bool: # 成功 if status_code == 200: + if is_empty_model_output(response.content): + log.warning(f"[ANTIGRAVITY] Model returned empty output, credential: {current_file}") + await record_api_call_error( + credential_manager, current_file, 461, + None, mode="antigravity", model_name=model_name, + error_message="可能触发外审导致空回" + ) + return build_empty_model_output_response() + # 检查是否为空回复 if not response.content or len(response.content) == 0: log.warning(f"[ANTIGRAVITY] 收到200响应但内容为空,凭证: {current_file}") @@ -682,11 +707,7 @@ def apply_cred_result(cred_result: Tuple[str, Dict[str, Any]]) -> bool: need_retry = True else: log.error(f"[ANTIGRAVITY] 空回复达到最大重试次数") - return Response( - content=json.dumps({"error": "服务返回空回复"}), - status_code=500, - media_type="application/json" - ) + return build_empty_model_output_response() else: # 正常响应 await record_api_call_success( @@ -729,7 +750,8 @@ def apply_cred_result(cred_result: Tuple[str, Dict[str, Any]]) -> bool: if next_cred_task is None and attempt < max_retries: next_cred_task = asyncio.create_task( credential_manager.get_valid_credential( - mode="antigravity", model_name=model_name + mode="antigravity", model_name=model_name, + session_key=session_key, exclude_credential=current_file ) ) diff --git a/src/api/empty_output.py b/src/api/empty_output.py new file mode 100644 index 000000000..ed27b334d --- /dev/null +++ b/src/api/empty_output.py @@ -0,0 +1,188 @@ +import json +from typing import Any, Mapping + +from fastapi import Response + +from src.converter.thoughtSignature_fix import ( + is_internal_placeholder_text, + is_skip_thought_signature_placeholder, +) + + +EMPTY_MODEL_OUTPUT_STATUS_CODE = 461 +EMPTY_MODEL_OUTPUT_MESSAGE = "可能触发外审导致空回" +EMPTY_MODEL_OUTPUT_STATUS = "EMPTY_MODEL_OUTPUT" + +_STRUCTURED_OUTPUT_KEYS = ( + "functionCall", + "function_call", + "functionResponse", + "function_response", + "inlineData", + "inline_data", + "fileData", + "file_data", + "executableCode", + "executable_code", + "codeExecutionResult", + "code_execution_result", +) + + +def build_empty_model_output_response() -> Response: + return Response( + content=json.dumps( + { + "error": { + "code": EMPTY_MODEL_OUTPUT_STATUS_CODE, + "message": EMPTY_MODEL_OUTPUT_MESSAGE, + "status": EMPTY_MODEL_OUTPUT_STATUS, + } + }, + ensure_ascii=False, + ), + status_code=EMPTY_MODEL_OUTPUT_STATUS_CODE, + media_type="application/json", + ) + + +def is_empty_model_output(raw_content: Any) -> bool: + if raw_content is None: + return True + + if isinstance(raw_content, bytes): + content_text = raw_content.decode("utf-8", errors="ignore") + elif isinstance(raw_content, str): + content_text = raw_content + else: + content_text = str(raw_content) + + if not content_text.strip(): + return True + + try: + payload = json.loads(content_text) + except (TypeError, ValueError): + return False + + return is_empty_model_output_payload(payload) + + +def is_empty_model_output_payload(payload: Any) -> bool: + response_data = _get_response_data(payload) + if response_data is None: + return False + + candidates = response_data.get("candidates") + if not isinstance(candidates, list) or not candidates: + return True + + return not any(_candidate_has_visible_output(candidate) for candidate in candidates) + + +def has_visible_model_output_payload(payload: Any) -> bool: + response_data = _get_response_data(payload) + if response_data is None: + return False + + candidates = response_data.get("candidates") + if not isinstance(candidates, list) or not candidates: + return False + + return any(_candidate_has_visible_output(candidate) for candidate in candidates) + + +def stream_chunk_has_visible_output(chunk: Any) -> bool: + if chunk is None: + return False + + if isinstance(chunk, bytes): + chunk_text = chunk.decode("utf-8", errors="ignore") + elif isinstance(chunk, str): + chunk_text = chunk + else: + chunk_text = str(chunk) + + for payload_text in _iter_stream_payloads(chunk_text): + try: + payload = json.loads(payload_text) + except (TypeError, ValueError): + continue + + if has_visible_model_output_payload(payload): + return True + + return False + + +def _get_response_data(payload: Any) -> Mapping[str, Any] | None: + if not isinstance(payload, Mapping): + return None + + if payload.get("error"): + return None + + response_data = payload.get("response") if isinstance(payload.get("response"), Mapping) else payload + if not isinstance(response_data, Mapping) or response_data.get("error"): + return None + + return response_data + + +def _iter_stream_payloads(chunk_text: str): + stripped = chunk_text.strip() + if not stripped: + return + + if stripped.startswith("data:"): + for line in chunk_text.splitlines(): + line = line.strip() + if not line.startswith("data:"): + continue + payload_text = line[5:].strip() + if payload_text and payload_text != "[DONE]": + yield payload_text + return + + if stripped != "[DONE]": + yield stripped + + +def _candidate_has_visible_output(candidate: Any) -> bool: + if not isinstance(candidate, Mapping): + return False + + content = candidate.get("content") + if not isinstance(content, Mapping): + return False + + parts = content.get("parts") + if not isinstance(parts, list) or not parts: + return False + + for part in parts: + if not isinstance(part, Mapping): + continue + + if is_skip_thought_signature_placeholder(part): + continue + + if any(key in part for key in _STRUCTURED_OUTPUT_KEYS): + return True + + if part.get("thought") is True: + continue + + if "text" not in part: + continue + + text = part.get("text") + if isinstance(text, str): + if is_internal_placeholder_text(text): + continue + if text.strip(): + return True + elif text is not None: + return True + + return False diff --git a/src/api/geminicli.py b/src/api/geminicli.py index 4f5f4b36a..b11079ca9 100644 --- a/src/api/geminicli.py +++ b/src/api/geminicli.py @@ -22,7 +22,13 @@ from log import log from src.credential_manager import credential_manager +from src.api.empty_output import ( + build_empty_model_output_response, + is_empty_model_output, + stream_chunk_has_visible_output, +) from src.httpx_client import stream_post_async, post_async +from src.session_affinity import extract_cache_session_key # 导入共同的基础功能 from src.api.utils import ( @@ -134,10 +140,11 @@ async def stream_request( """ # 获取有效凭证 model_name = body.get("model", "") + session_key = extract_cache_session_key(body, headers) # 1. 获取有效凭证 cred_result = await credential_manager.get_valid_credential( - mode="geminicli", model_name=model_name + mode="geminicli", model_name=model_name, session_key=session_key ) if not cred_result: @@ -184,7 +191,8 @@ async def stream_request( async def refresh_credential_fast(): nonlocal current_file, credential_data, auth_headers, final_payload cred_result = await credential_manager.get_valid_credential( - mode="geminicli", model_name=model_name + mode="geminicli", model_name=model_name, session_key=session_key, + exclude_credential=current_file ) if not cred_result: return None @@ -217,6 +225,7 @@ def apply_cred_result(cred_result: Tuple[str, Dict[str, Any]]) -> bool: for attempt in range(max_retries + 1): success_recorded = False # 标记是否已记录成功 need_retry = False # 标记是否需要重试 + buffered_chunks = [] try: async for chunk in stream_post_async( @@ -253,7 +262,8 @@ def apply_cred_result(cred_result: Tuple[str, Dict[str, Any]]) -> bool: if next_cred_task is None and attempt < max_retries: next_cred_task = asyncio.create_task( credential_manager.get_valid_credential( - mode="geminicli", model_name=model_name + mode="geminicli", model_name=model_name, + session_key=session_key, exclude_credential=current_file ) ) @@ -303,7 +313,8 @@ def apply_cred_result(cred_result: Tuple[str, Dict[str, Any]]) -> bool: if next_cred_task is None and attempt < max_retries: next_cred_task = asyncio.create_task( credential_manager.get_valid_credential( - mode="geminicli", model_name=model_name + mode="geminicli", model_name=model_name, + session_key=session_key, exclude_credential=current_file ) ) @@ -329,19 +340,37 @@ def apply_cred_result(cred_result: Tuple[str, Dict[str, Any]]) -> bool: # 不是Response,说明是真流,直接yield返回 # 只在第一个chunk时记录成功 if not success_recorded: + buffered_chunks.append(chunk) + if not stream_chunk_has_visible_output(chunk): + continue + await record_api_call_success( credential_manager, current_file, mode="geminicli", model_name=model_name ) success_recorded = True log.debug(f"[GEMINICLI STREAM] 开始接收流式响应,模型: {model_name}") - yield chunk + for buffered_chunk in buffered_chunks: + yield buffered_chunk + buffered_chunks = [] + else: + yield chunk # 流式请求完成,检查结果 if success_recorded: log.debug(f"[GEMINICLI STREAM] 流式响应完成,模型: {model_name}") return + if not need_retry: + log.warning(f"[GEMINICLI STREAM] Model returned empty output, credential: {current_file}") + await record_api_call_error( + credential_manager, current_file, 461, + None, mode="geminicli", model_name=model_name, + error_message="可能触发外审导致空回" + ) + yield build_empty_model_output_response() + return + # 统一处理重试 if need_retry: # 如果已经是最后一次尝试,不再重试,直接返回错误 @@ -425,10 +454,11 @@ async def non_stream_request( """ # 获取有效凭证 model_name = body.get("model", "") + session_key = extract_cache_session_key(body, headers) # 1. 获取有效凭证 cred_result = await credential_manager.get_valid_credential( - mode="geminicli", model_name=model_name + mode="geminicli", model_name=model_name, session_key=session_key ) if not cred_result: @@ -473,7 +503,8 @@ async def non_stream_request( async def refresh_credential_fast(): nonlocal current_file, credential_data, auth_headers, final_payload cred_result = await credential_manager.get_valid_credential( - mode="geminicli", model_name=model_name + mode="geminicli", model_name=model_name, session_key=session_key, + exclude_credential=current_file ) if not cred_result: return None @@ -516,6 +547,15 @@ def apply_cred_result(cred_result: Tuple[str, Dict[str, Any]]) -> bool: # 成功 if status_code == 200: + if is_empty_model_output(response.content): + log.warning(f"[NON-STREAM] Model returned empty output, credential: {current_file}") + await record_api_call_error( + credential_manager, current_file, 461, + None, mode="geminicli", model_name=model_name, + error_message="可能触发外审导致空回" + ) + return build_empty_model_output_response() + await record_api_call_success( credential_manager, current_file, mode="geminicli", model_name=model_name ) @@ -566,7 +606,8 @@ def apply_cred_result(cred_result: Tuple[str, Dict[str, Any]]) -> bool: if next_cred_task is None and attempt < max_retries: next_cred_task = asyncio.create_task( credential_manager.get_valid_credential( - mode="geminicli", model_name=model_name + mode="geminicli", model_name=model_name, + session_key=session_key, exclude_credential=current_file ) ) @@ -631,7 +672,8 @@ def apply_cred_result(cred_result: Tuple[str, Dict[str, Any]]) -> bool: if next_cred_task is None and attempt < max_retries: next_cred_task = asyncio.create_task( credential_manager.get_valid_credential( - mode="geminicli", model_name=model_name + mode="geminicli", model_name=model_name, + session_key=session_key, exclude_credential=current_file ) ) diff --git a/src/api/utils.py b/src/api/utils.py index c90dace7d..c257c8d62 100644 --- a/src/api/utils.py +++ b/src/api/utils.py @@ -18,6 +18,7 @@ get_retry_429_max_retries, ) from log import log +from src.api.empty_output import build_empty_model_output_response, is_empty_model_output_payload from src.credential_manager import CredentialManager @@ -390,11 +391,7 @@ async def collect_streaming_response(stream_generator) -> Response: # 如果没有收集到任何数据,返回错误 if not has_data: log.error(f"[STREAM COLLECTOR] No data collected from stream after {line_count} lines") - return Response( - content=json.dumps({"error": "No data collected from stream"}), - status_code=500, - media_type="application/json" - ) + return build_empty_model_output_response() # 组装最终的parts final_parts = [] @@ -433,6 +430,10 @@ async def collect_streaming_response(stream_generator) -> Response: merged_response = merged_response["response"] # 返回纯JSON格式 + if is_empty_model_output_payload(merged_response): + log.warning("[STREAM COLLECTOR] Collected stream contains empty model output") + return build_empty_model_output_response() + return Response( content=json.dumps(merged_response, ensure_ascii=False).encode('utf-8'), status_code=200, diff --git a/src/credential_manager.py b/src/credential_manager.py index b99feb775..63ded5ef2 100644 --- a/src/credential_manager.py +++ b/src/credential_manager.py @@ -3,6 +3,8 @@ """ import asyncio +import hashlib +import os import time from datetime import datetime, timezone from typing import Any, Dict, List, Optional, Tuple @@ -12,6 +14,10 @@ from src.google_oauth_api import Credentials from src.storage_adapter import get_storage_adapter + +SESSION_BINDING_TTL_SECONDS = 3 * 60 * 60 + + class CredentialManager: """ 统一凭证管理器 @@ -22,6 +28,8 @@ def __init__(self): # 核心状态 self._initialized = False self._storage_adapter = None + self._session_bindings: Dict[str, Tuple[str, float]] = {} + self._session_lock = asyncio.Lock() # 并发控制(简化) # 后端数据库自行处理并发,credential_manager 不再使用本地锁 @@ -46,8 +54,171 @@ async def close(self): self._initialized = False log.debug("Credential manager closed") + def _session_binding_key( + self, mode: str, model_name: Optional[str], session_key: Optional[str] + ) -> Optional[str]: + if not session_key: + return None + return f"{mode}:{model_name or ''}:{session_key}" + + def _session_log_id(self, binding_key: str) -> str: + return hashlib.sha256(binding_key.encode("utf-8")).hexdigest()[:12] + + async def _get_session_binding(self, binding_key: str) -> Optional[str]: + async with self._session_lock: + binding = self._session_bindings.get(binding_key) + if not binding: + return None + filename, expires_at = binding + if expires_at <= time.time(): + self._session_bindings.pop(binding_key, None) + return None + return filename + + async def _remember_session_binding(self, binding_key: Optional[str], filename: str) -> None: + if not binding_key or not filename: + return + async with self._session_lock: + self._session_bindings[binding_key] = ( + os.path.basename(filename), + time.time() + SESSION_BINDING_TTL_SECONDS, + ) + + async def _forget_session_binding(self, binding_key: Optional[str]) -> None: + if not binding_key: + return + async with self._session_lock: + self._session_bindings.pop(binding_key, None) + + async def _get_bound_credential_if_available( + self, + filename: str, + *, + mode: str, + model_name: Optional[str], + exclude_credential: Optional[str], + ) -> Optional[Tuple[str, Dict[str, Any]]]: + if exclude_credential and os.path.basename(filename) == os.path.basename(exclude_credential): + return None + + state = await self._storage_adapter.get_credential_state(filename, mode=mode) + if state.get("disabled"): + return None + + model_lower = (model_name or "").lower() + if model_name: + cooldown_until = (state.get("model_cooldowns") or {}).get(model_name) + if cooldown_until is not None: + try: + if time.time() < float(cooldown_until): + return None + except (TypeError, ValueError): + return None + + if mode == "geminicli": + if "pro" in model_lower and state.get("tier") == "free": + return None + if "preview" in model_lower and state.get("preview") is False: + return None + + credential_data = await self._storage_adapter.get_credential(filename, mode=mode) + if not credential_data: + return None + + if mode == "antigravity": + credential_data["enable_credit"] = bool(state.get("enable_credit", False)) + + return os.path.basename(filename), credential_data + + def _credential_state_allows_model( + self, + state: Dict[str, Any], + *, + mode: str, + model_name: Optional[str], + ) -> bool: + if state.get("disabled"): + return False + + model_lower = (model_name or "").lower() + if model_name: + cooldown_until = (state.get("model_cooldowns") or {}).get(model_name) + if cooldown_until is not None: + try: + if time.time() < float(cooldown_until): + return False + except (TypeError, ValueError): + return False + + if mode == "geminicli": + if "pro" in model_lower and state.get("tier") == "free": + return False + if "preview" in model_lower and state.get("preview") is False: + return False + + return True + + async def _get_session_routed_credential( + self, + *, + binding_key: str, + mode: str, + model_name: Optional[str], + exclude_credential: Optional[str], + ) -> Optional[Tuple[str, Dict[str, Any]]]: + states = await self._storage_adapter.get_all_credential_states(mode=mode) + if not states: + return None + + excluded = os.path.basename(exclude_credential) if exclude_credential else None + candidates: List[Tuple[str, Dict[str, Any]]] = [] + + for raw_filename, state in states.items(): + filename = os.path.basename(raw_filename) + if excluded and filename == excluded: + continue + if not self._credential_state_allows_model( + state, + mode=mode, + model_name=model_name, + ): + continue + candidates.append((filename, state)) + + if not candidates: + return None + + ordered_candidates = sorted( + candidates, + key=lambda item: hashlib.sha256( + f"{binding_key}\0{item[0]}".encode("utf-8") + ).digest(), + reverse=True, + ) + + for filename, state in ordered_candidates: + credential_data = await self._storage_adapter.get_credential(filename, mode=mode) + if not credential_data: + continue + + if mode == "antigravity": + credential_data["enable_credit"] = bool(state.get("enable_credit", False)) + + log.info( + "Session route selected: " + f"session={self._session_log_id(binding_key)}, " + f"credential={filename}, mode={mode}, model={model_name}" + ) + return filename, credential_data + + return None + async def get_valid_credential( - self, mode: str = "geminicli", model_name: Optional[str] = None + self, + mode: str = "geminicli", + model_name: Optional[str] = None, + session_key: Optional[str] = None, + exclude_credential: Optional[str] = None, ) -> Optional[Tuple[str, Dict[str, Any]]]: """ 获取有效的凭证 - 随机负载均衡版 @@ -63,9 +234,63 @@ async def get_valid_credential( - antigravity: 完整模型名(如 "gemini-2.0-flash-exp") """ await self._ensure_initialized() + binding_key = self._session_binding_key(mode, model_name, session_key) + + if binding_key: + bound_filename = await self._get_session_binding(binding_key) + if bound_filename: + bound_result = await self._get_bound_credential_if_available( + bound_filename, + mode=mode, + model_name=model_name, + exclude_credential=exclude_credential, + ) + if bound_result: + filename, credential_data = bound_result + if await self._should_refresh_token(credential_data): + refreshed_data = await self._refresh_token(credential_data, filename, mode=mode) + if refreshed_data: + log.info( + "Session route hit after refresh: " + f"session={self._session_log_id(binding_key)}, " + f"credential={filename}, mode={mode}, model={model_name}" + ) + await self._remember_session_binding(binding_key, filename) + return filename, refreshed_data + await self._forget_session_binding(binding_key) + else: + log.info( + "Session route hit: " + f"session={self._session_log_id(binding_key)}, " + f"credential={filename}, mode={mode}, model={model_name}" + ) + await self._remember_session_binding(binding_key, filename) + return filename, credential_data + else: + await self._forget_session_binding(binding_key) + + routed_result = await self._get_session_routed_credential( + binding_key=binding_key, + mode=mode, + model_name=model_name, + exclude_credential=exclude_credential, + ) + if routed_result: + filename, credential_data = routed_result + if await self._should_refresh_token(credential_data): + log.debug(f"Token needs refresh: {filename} (mode={mode})") + refreshed_data = await self._refresh_token(credential_data, filename, mode=mode) + if refreshed_data: + log.debug(f"Token refreshed: {filename} (mode={mode})") + await self._remember_session_binding(binding_key, filename) + return filename, refreshed_data + await self._forget_session_binding(binding_key) + else: + await self._remember_session_binding(binding_key, filename) + return filename, credential_data # 最多重试3次 - max_retries = 3 + max_retries = 20 if exclude_credential else 3 for attempt in range(max_retries): result = await self._storage_adapter._backend.get_next_available_credential( mode=mode, model_name=model_name @@ -78,6 +303,8 @@ async def get_valid_credential( return None filename, credential_data = result + if exclude_credential and os.path.basename(filename) == os.path.basename(exclude_credential): + continue # Token 刷新检查 if await self._should_refresh_token(credential_data): @@ -87,6 +314,7 @@ async def get_valid_credential( # 刷新成功,返回凭证 credential_data = refreshed_data log.debug(f"Token刷新成功: {filename} (mode={mode})") + await self._remember_session_binding(binding_key, filename) return filename, credential_data else: # 刷新失败(_refresh_token内部已自动禁用失效凭证) @@ -95,6 +323,7 @@ async def get_valid_credential( continue else: # Token有效,直接返回 + await self._remember_session_binding(binding_key, filename) return filename, credential_data # 重试次数用尽 diff --git a/src/google_oauth_api.py b/src/google_oauth_api.py index f2681ddfe..d59f22451 100644 --- a/src/google_oauth_api.py +++ b/src/google_oauth_api.py @@ -631,6 +631,40 @@ def _map_raw_tier(raw_tier: Optional[str]) -> Optional[str]: return None, subscription_tier +async def fetch_credit_amount( + access_token: str, + user_agent: str, + api_base_url: str, +) -> Optional[int]: + """ + Fetch remaining credits with loadCodeAssist only. + + This is intentionally read-only and does not fall back to onboardUser, so it + is safe for background UI refreshes. + """ + headers = { + 'User-Agent': user_agent, + 'Authorization': f'Bearer {access_token}', + 'Content-Type': 'application/json', + 'Accept-Encoding': 'gzip' + } + + raw_credit_amount = None + + try: + _, _, raw_credit_amount = await _try_load_code_assist(api_base_url, headers) + if raw_credit_amount is None: + return None + + return int(raw_credit_amount) + except (TypeError, ValueError): + log.warning(f"[fetch_credit_amount] Invalid credit_amount: {raw_credit_amount}") + return None + except Exception as e: + log.warning(f"[fetch_credit_amount] Failed: {type(e).__name__}: {e}") + return None + + async def _try_load_code_assist( api_base_url: str, headers: dict diff --git a/src/panel/creds.py b/src/panel/creds.py index 9f64df0a0..84b6c1413 100644 --- a/src/panel/creds.py +++ b/src/panel/creds.py @@ -8,7 +8,7 @@ import os import time import zipfile -from typing import Any, List +from typing import Any, Dict, List, Optional from fastapi import APIRouter, Depends, File, HTTPException, UploadFile, Response from fastapi.responses import JSONResponse @@ -22,7 +22,7 @@ from src.storage_adapter import get_storage_adapter from src.utils import verify_panel_token, GEMINICLI_USER_AGENT, ANTIGRAVITY_USER_AGENT from src.api.antigravity import fetch_quota_info -from src.google_oauth_api import Credentials, fetch_project_id_and_tier +from src.google_oauth_api import Credentials, fetch_credit_amount, fetch_project_id_and_tier from config import get_code_assist_endpoint, get_antigravity_api_url from .utils import validate_mode @@ -102,6 +102,42 @@ async def clear_all_model_cooldowns_for_credential( log.warning(f"清空模型CD时出错: {filename} (mode={mode}), error={e}") +def _normalize_credit_amount(value: Any) -> Optional[float]: + """Return a numeric credit amount when the provider sends one.""" + if value is None: + return None + + try: + return float(value) + except (TypeError, ValueError): + try: + return float(str(value).replace(",", "").strip()) + except (TypeError, ValueError): + return None + + +def _average_quota_remaining(models: Dict[str, Any]) -> Optional[float]: + """Average model remaining fractions for one credential.""" + remaining_values: List[float] = [] + + for quota_data in models.values(): + if not isinstance(quota_data, dict): + continue + + remaining = quota_data.get("remaining") + try: + remaining_float = float(remaining) + except (TypeError, ValueError): + continue + + remaining_values.append(max(0.0, min(1.0, remaining_float))) + + if not remaining_values: + return None + + return sum(remaining_values) / len(remaining_values) + + async def upload_credentials_common( files: List[UploadFile], mode: str = "geminicli" ) -> JSONResponse: @@ -1213,6 +1249,218 @@ async def get_credential_quota( raise HTTPException(status_code=500, detail=f"获取额度失败: {str(e)}") +@router.get("/antigravity-credit-summary") +async def get_antigravity_credit_summary( + token: str = Depends(verify_panel_token), +): + """ + Return aggregate Antigravity quota and credit data for enabled credentials. + """ + try: + storage_adapter = await get_storage_adapter() + all_states = await storage_adapter.get_all_credential_states(mode="antigravity") + enabled_filenames = [ + filename + for filename, state in all_states.items() + if not state.get("disabled", False) + ] + + if not enabled_filenames: + return JSONResponse(content={ + "success": True, + "enabled_accounts": 0, + "checked_accounts": 0, + "failed_accounts": 0, + "credit_accounts": 0, + "quota_accounts": 0, + "total_remaining_credits": 0, + "remaining_fraction": None, + "remaining_percent": None, + "model_summaries": [], + "updated_at": time.time(), + "errors": [], + }) + + api_base_url = await get_antigravity_api_url() + semaphore = asyncio.Semaphore(5) + + async def fetch_one(filename: str) -> Dict[str, Any]: + result: Dict[str, Any] = { + "filename": os.path.basename(filename), + "success": False, + "credit_amount": None, + "quota_remaining_fraction": None, + "quota_model_count": 0, + "models": {}, + "error": None, + } + + async with semaphore: + try: + credential_data = await storage_adapter.get_credential( + filename, mode="antigravity" + ) + if not credential_data: + result["error"] = "credential not found" + return result + + creds = Credentials.from_dict(credential_data) + token_refreshed = await creds.refresh_if_needed() + updated_data = creds.to_dict() + + if token_refreshed or updated_data != credential_data: + await storage_adapter.store_credential( + filename, updated_data, mode="antigravity" + ) + credential_data = updated_data + + access_token = ( + credential_data.get("access_token") + or credential_data.get("token") + or creds.access_token + ) + if not access_token: + result["error"] = "missing access token" + return result + + quota_info, credit_amount = await asyncio.gather( + fetch_quota_info(access_token), + fetch_credit_amount( + access_token=access_token, + user_agent=ANTIGRAVITY_USER_AGENT, + api_base_url=api_base_url, + ), + ) + + normalized_credit = _normalize_credit_amount(credit_amount) + if normalized_credit is not None: + result["credit_amount"] = normalized_credit + + if quota_info.get("success"): + models = quota_info.get("models", {}) or {} + result["quota_model_count"] = len(models) + result["quota_remaining_fraction"] = _average_quota_remaining(models) + + model_entries: Dict[str, Any] = {} + for model_name, quota_data in models.items(): + if not isinstance(quota_data, dict): + continue + + try: + remaining_float = float(quota_data.get("remaining")) + except (TypeError, ValueError): + continue + + remaining_float = max(0.0, min(1.0, remaining_float)) + model_entries[model_name] = { + "remaining_fraction": remaining_float, + "remaining_percent": round(remaining_float * 100, 2), + } + + result["models"] = model_entries + else: + result["error"] = quota_info.get("error", "quota fetch failed") + + result["success"] = ( + result["credit_amount"] is not None + or result["quota_remaining_fraction"] is not None + ) + return result + + except Exception as e: + result["error"] = str(e) + log.warning( + f"[ANTIGRAVITY SUMMARY] Failed to fetch {filename}: {e}" + ) + return result + + results = await asyncio.gather(*(fetch_one(filename) for filename in enabled_filenames)) + + quota_fractions = [ + item["quota_remaining_fraction"] + for item in results + if item.get("quota_remaining_fraction") is not None + ] + credit_amounts = [ + item["credit_amount"] + for item in results + if item.get("credit_amount") is not None + ] + + remaining_fraction = ( + sum(quota_fractions) / len(quota_fractions) + if quota_fractions + else None + ) + total_remaining_credits = sum(credit_amounts) if credit_amounts else None + + model_buckets: Dict[str, Dict[str, Any]] = {} + for item in results: + for model_name, model_data in (item.get("models") or {}).items(): + remaining_fraction = model_data.get("remaining_fraction") + if remaining_fraction is None: + continue + + bucket = model_buckets.setdefault( + model_name, + { + "remaining_values": [], + }, + ) + bucket["remaining_values"].append(remaining_fraction) + + model_summaries = [] + for model_name, bucket in model_buckets.items(): + remaining_values = bucket["remaining_values"] + if not remaining_values: + continue + + average_remaining = sum(remaining_values) / len(remaining_values) + exhausted_accounts = sum(1 for value in remaining_values if value <= 0) + + model_summaries.append({ + "model": model_name, + "account_count": len(remaining_values), + "exhausted_accounts": exhausted_accounts, + "remaining_fraction": average_remaining, + "remaining_percent": round(average_remaining * 100, 2), + }) + + model_summaries.sort(key=lambda item: (item["remaining_percent"], item["model"])) + + failed_results = [item for item in results if not item.get("success")] + errors = [ + { + "filename": item.get("filename"), + "error": item.get("error") or "no quota or credit data", + } + for item in failed_results + ][:10] + + return JSONResponse(content={ + "success": True, + "enabled_accounts": len(enabled_filenames), + "checked_accounts": len(results), + "failed_accounts": len(failed_results), + "credit_accounts": len(credit_amounts), + "quota_accounts": len(quota_fractions), + "total_remaining_credits": total_remaining_credits, + "remaining_fraction": remaining_fraction, + "remaining_percent": ( + round(remaining_fraction * 100, 2) + if remaining_fraction is not None + else None + ), + "model_summaries": model_summaries, + "updated_at": time.time(), + "errors": errors, + }) + + except Exception as e: + log.error(f"[ANTIGRAVITY SUMMARY] Failed to build summary: {e}") + raise HTTPException(status_code=500, detail=f"获取总额度失败: {str(e)}") + + @router.post("/configure-preview/{filename}") async def configure_preview_channel( filename: str, diff --git a/src/router/antigravity/anthropic.py b/src/router/antigravity/anthropic.py index e608b8a60..76f4e499b 100644 --- a/src/router/antigravity/anthropic.py +++ b/src/router/antigravity/anthropic.py @@ -48,6 +48,7 @@ # 本地模块 - 数据模型 from src.models import ClaudeRequest, model_to_dict +from src.session_affinity import extract_cache_session_key # 本地模块 - 任务管理 from src.task_manager import create_managed_task @@ -66,6 +67,7 @@ @router.post("/antigravity/v1/messages") async def messages( claude_request: ClaudeRequest, + request: Request, _token: str = Depends(authenticate_bearer) ): """ @@ -79,6 +81,7 @@ async def messages( # 转换为字典 normalized_dict = model_to_dict(claude_request) + cache_session_key = extract_cache_session_key(normalized_dict, request.headers) # 健康检查 if is_health_check_request(normalized_dict, format="anthropic"): @@ -114,7 +117,8 @@ async def messages( # 准备API请求格式 - 提取model并将其他字段放入request中 api_request = { "model": gemini_dict.pop("model"), - "request": gemini_dict + "request": gemini_dict, + "cache_session_key": cache_session_key } # ========== 非流式请求 ========== diff --git a/src/router/antigravity/gemini.py b/src/router/antigravity/gemini.py index 8398463c8..47806bd71 100644 --- a/src/router/antigravity/gemini.py +++ b/src/router/antigravity/gemini.py @@ -48,6 +48,7 @@ # 本地模块 - 数据模型 from src.models import GeminiRequest, model_to_dict +from src.session_affinity import extract_cache_session_key # 本地模块 - 任务管理 from src.task_manager import create_managed_task @@ -64,6 +65,7 @@ @router.post("/antigravity/v1/models/{model:path}:generateContent") async def generate_content( gemini_request: "GeminiRequest", + request: Request, model: str = Path(..., description="Model name"), api_key: str = Depends(authenticate_gemini_flexible), ): @@ -79,6 +81,7 @@ async def generate_content( # 转换为字典 normalized_dict = model_to_dict(gemini_request) + cache_session_key = extract_cache_session_key(normalized_dict, request.headers) # 健康检查 if is_health_check_request(normalized_dict, format="gemini"): @@ -103,7 +106,8 @@ async def generate_content( # 准备API请求格式 - 提取model并将其他字段放入request中 api_request = { "model": normalized_dict.pop("model"), - "request": normalized_dict + "request": normalized_dict, + "cache_session_key": cache_session_key } # 调用 API 层的非流式请求 @@ -130,6 +134,7 @@ async def generate_content( @router.post("/antigravity/v1/models/{model:path}:streamGenerateContent") async def stream_generate_content( gemini_request: GeminiRequest, + request: Request, model: str = Path(..., description="Model name"), api_key: str = Depends(authenticate_gemini_flexible), ): @@ -145,6 +150,7 @@ async def stream_generate_content( # 转换为字典 normalized_dict = model_to_dict(gemini_request) + cache_session_key = extract_cache_session_key(normalized_dict, request.headers) # 处理模型名称和功能检测 use_fake_streaming = is_fake_streaming_model(model) @@ -164,7 +170,8 @@ async def fake_stream_generator(): # 准备API请求格式 - 提取model并将其他字段放入request中 api_request = { "model": normalized_req.pop("model"), - "request": normalized_req + "request": normalized_req, + "cache_session_key": cache_session_key } response = await non_stream_request(body=api_request) @@ -229,7 +236,8 @@ async def anti_truncation_generator(): # 准备API请求格式 - 提取model并将其他字段放入request中 api_request = { "model": normalized_req.pop("model") if "model" in normalized_req else real_model, - "request": normalized_req + "request": normalized_req, + "cache_session_key": cache_session_key } max_attempts = await get_anti_truncation_max_attempts() @@ -315,7 +323,8 @@ async def normal_stream_generator(): # 准备API请求格式 - 提取model并将其他字段放入request中 api_request = { "model": normalized_req.pop("model"), - "request": normalized_req + "request": normalized_req, + "cache_session_key": cache_session_key } # 所有流式请求都使用非 native 模式(SSE格式)并展开 response 包装 diff --git a/src/router/antigravity/openai.py b/src/router/antigravity/openai.py index f914bd873..8930055b0 100644 --- a/src/router/antigravity/openai.py +++ b/src/router/antigravity/openai.py @@ -16,7 +16,7 @@ import json # 第三方库 -from fastapi import APIRouter, Depends, HTTPException +from fastapi import APIRouter, Depends, HTTPException, Request from fastapi.responses import JSONResponse, StreamingResponse # 本地模块 - 配置和日志 @@ -48,6 +48,7 @@ # 本地模块 - 数据模型 from src.models import OpenAIChatCompletionRequest, model_to_dict +from src.session_affinity import extract_cache_session_key # 本地模块 - 任务管理 from src.task_manager import create_managed_task @@ -63,6 +64,7 @@ @router.post("/antigravity/v1/chat/completions") async def chat_completions( openai_request: OpenAIChatCompletionRequest, + request: Request, token: str = Depends(authenticate_bearer) ): """ @@ -76,6 +78,7 @@ async def chat_completions( # 转换为字典 normalized_dict = model_to_dict(openai_request) + cache_session_key = extract_cache_session_key(normalized_dict, request.headers) # 健康检查 if is_health_check_request(normalized_dict, format="openai"): @@ -111,7 +114,8 @@ async def chat_completions( # 准备API请求格式 - 提取model并将其他字段放入request中 api_request = { "model": gemini_dict.pop("model"), - "request": gemini_dict + "request": gemini_dict, + "cache_session_key": cache_session_key } # ========== 非流式请求 ========== diff --git a/src/router/geminicli/anthropic.py b/src/router/geminicli/anthropic.py index f9f9e4dc2..c7e2f45c5 100644 --- a/src/router/geminicli/anthropic.py +++ b/src/router/geminicli/anthropic.py @@ -48,6 +48,7 @@ # 本地模块 - 数据模型 from src.models import ClaudeRequest, model_to_dict +from src.session_affinity import extract_cache_session_key # 本地模块 - 任务管理 from src.task_manager import create_managed_task @@ -66,6 +67,7 @@ @router.post("/v1/messages") async def messages( claude_request: ClaudeRequest, + request: Request, token: str = Depends(authenticate_bearer) ): """ @@ -79,6 +81,7 @@ async def messages( # 转换为字典 normalized_dict = model_to_dict(claude_request) + cache_session_key = extract_cache_session_key(normalized_dict, request.headers) # 健康检查 if is_health_check_request(normalized_dict, format="anthropic"): @@ -114,7 +117,8 @@ async def messages( # 准备API请求格式 - 提取model并将其他字段放入request中 api_request = { "model": gemini_dict.pop("model"), - "request": gemini_dict + "request": gemini_dict, + "cache_session_key": cache_session_key } # ========== 非流式请求 ========== diff --git a/src/router/geminicli/gemini.py b/src/router/geminicli/gemini.py index cd21de033..dceb8962d 100644 --- a/src/router/geminicli/gemini.py +++ b/src/router/geminicli/gemini.py @@ -46,6 +46,7 @@ # 本地模块 - 数据模型 from src.models import GeminiRequest, model_to_dict +from src.session_affinity import extract_cache_session_key # 本地模块 - 任务管理 from src.task_manager import create_managed_task @@ -62,6 +63,7 @@ @router.post("/v1/models/{model:path}:generateContent") async def generate_content( gemini_request: "GeminiRequest", + request: Request, model: str = Path(..., description="Model name"), api_key: str = Depends(authenticate_gemini_flexible), ): @@ -77,6 +79,7 @@ async def generate_content( # 转换为字典 normalized_dict = model_to_dict(gemini_request) + cache_session_key = extract_cache_session_key(normalized_dict, request.headers) # 健康检查 if is_health_check_request(normalized_dict, format="gemini"): @@ -101,7 +104,8 @@ async def generate_content( # 准备API请求格式 - 提取model并将其他字段放入request中 api_request = { "model": normalized_dict.pop("model"), - "request": normalized_dict + "request": normalized_dict, + "cache_session_key": cache_session_key } # 调用 API 层的非流式请求 @@ -127,6 +131,7 @@ async def generate_content( @router.post("/v1/models/{model:path}:streamGenerateContent") async def stream_generate_content( gemini_request: GeminiRequest, + request: Request, model: str = Path(..., description="Model name"), api_key: str = Depends(authenticate_gemini_flexible), ): @@ -142,6 +147,7 @@ async def stream_generate_content( # 转换为字典 normalized_dict = model_to_dict(gemini_request) + cache_session_key = extract_cache_session_key(normalized_dict, request.headers) # 处理模型名称和功能检测 use_fake_streaming = is_fake_streaming_model(model) @@ -161,7 +167,8 @@ async def fake_stream_generator(): # 准备API请求格式 - 提取model并将其他字段放入request中 api_request = { "model": normalized_req.pop("model"), - "request": normalized_req + "request": normalized_req, + "cache_session_key": cache_session_key } response = await non_stream_request(body=api_request) @@ -226,7 +233,8 @@ async def anti_truncation_generator(): # 准备API请求格式 - 提取model并将其他字段放入request中 api_request = { "model": normalized_req.pop("model") if "model" in normalized_req else real_model, - "request": normalized_req + "request": normalized_req, + "cache_session_key": cache_session_key } max_attempts = await get_anti_truncation_max_attempts() @@ -312,7 +320,8 @@ async def normal_stream_generator(): # 准备API请求格式 - 提取model并将其他字段放入request中 api_request = { "model": normalized_req.pop("model"), - "request": normalized_req + "request": normalized_req, + "cache_session_key": cache_session_key } # 所有流式请求都使用非 native 模式(SSE格式)并展开 response 包装 diff --git a/src/router/geminicli/openai.py b/src/router/geminicli/openai.py index 353b72500..67f6cbe88 100644 --- a/src/router/geminicli/openai.py +++ b/src/router/geminicli/openai.py @@ -16,7 +16,7 @@ import json # 第三方库 -from fastapi import APIRouter, Depends, HTTPException +from fastapi import APIRouter, Depends, HTTPException, Request from fastapi.responses import JSONResponse, StreamingResponse # 本地模块 - 配置和日志 @@ -48,6 +48,7 @@ # 本地模块 - 数据模型 from src.models import OpenAIChatCompletionRequest, model_to_dict +from src.session_affinity import extract_cache_session_key # 本地模块 - 任务管理 from src.task_manager import create_managed_task @@ -63,6 +64,7 @@ @router.post("/v1/chat/completions") async def chat_completions( openai_request: OpenAIChatCompletionRequest, + request: Request, token: str = Depends(authenticate_bearer) ): """ @@ -76,6 +78,7 @@ async def chat_completions( # 转换为字典 normalized_dict = model_to_dict(openai_request) + cache_session_key = extract_cache_session_key(normalized_dict, request.headers) # 健康检查 if is_health_check_request(normalized_dict, format="openai"): @@ -111,7 +114,8 @@ async def chat_completions( # 准备API请求格式 - 提取model并将其他字段放入request中 api_request = { "model": gemini_dict.pop("model"), - "request": gemini_dict + "request": gemini_dict, + "cache_session_key": cache_session_key } # ========== 非流式请求 ========== diff --git a/src/session_affinity.py b/src/session_affinity.py new file mode 100644 index 000000000..40e1986a6 --- /dev/null +++ b/src/session_affinity.py @@ -0,0 +1,181 @@ +""" +Helpers for cache-friendly credential routing. + +The goal is simple: requests from the same chat/task should usually hit the +same Google account so upstream prefix cache has a better chance to work. +""" + +import hashlib +import json +import re +from typing import Any, Mapping, Optional + + +_CLAUDE_SESSION_RE = re.compile(r"_session_([a-fA-F0-9-]+)$") + + +def extract_cache_session_key( + payload: Optional[Mapping[str, Any]], + headers: Optional[Mapping[str, str]] = None, +) -> Optional[str]: + if not isinstance(payload, Mapping): + return None + + explicit = payload.get("cache_session_key") + if isinstance(explicit, str) and explicit.strip(): + return explicit.strip() + + header_key = _session_key_from_headers(headers) + if header_key: + return header_key + + metadata_key = _session_key_from_metadata(payload.get("metadata")) + if metadata_key: + return metadata_key + + conversation_id = payload.get("conversation_id") + if isinstance(conversation_id, str) and conversation_id.strip(): + return f"conv:{conversation_id.strip()}" + + request_payload = payload.get("request") + if isinstance(request_payload, Mapping): + request_key = _session_key_from_gemini_like_payload(request_payload) + if request_key: + return request_key + + gemini_key = _session_key_from_gemini_like_payload(payload) + if gemini_key: + return gemini_key + + first_user_text = _first_user_text(payload) + if first_user_text: + digest = hashlib.sha256(first_user_text.encode("utf-8")).hexdigest()[:16] + return f"msg:{digest}" + + return None + + +def _session_key_from_headers(headers: Optional[Mapping[str, str]]) -> Optional[str]: + if headers is None: + return None + + header_names = ( + ("x-session-id", "header"), + ("session_id", "codex"), + ("x-amp-thread-id", "amp"), + ("x-client-request-id", "clientreq"), + ) + + for header_name, prefix in header_names: + value = _get_header(headers, header_name) + if value: + return f"{prefix}:{value}" + + return None + + +def _get_header(headers: Mapping[str, str], name: str) -> Optional[str]: + value = None + get_method = getattr(headers, "get", None) + if callable(get_method): + value = get_method(name) + if value is None: + value = get_method(name.lower()) + if value is None: + value = get_method(name.upper()) + if value is None: + return None + value = str(value).strip() + return value or None + + +def _session_key_from_metadata(metadata: Any) -> Optional[str]: + if not isinstance(metadata, Mapping): + return None + + user_id = metadata.get("user_id") + if not isinstance(user_id, str) or not user_id.strip(): + return None + + user_id = user_id.strip() + match = _CLAUDE_SESSION_RE.search(user_id) + if match: + return f"claude:{match.group(1)}" + + if user_id.startswith("{"): + try: + parsed = json.loads(user_id) + session_id = parsed.get("session_id") if isinstance(parsed, Mapping) else None + if isinstance(session_id, str) and session_id.strip(): + return f"claude:{session_id.strip()}" + except Exception: + pass + + return f"user:{user_id}" + + +def _session_key_from_gemini_like_payload(payload: Mapping[str, Any]) -> Optional[str]: + session_id = payload.get("sessionId") or payload.get("session_id") + if isinstance(session_id, str) and session_id.strip(): + return f"gemini-session:{session_id.strip()}" + if isinstance(session_id, int): + return f"gemini-session:{session_id}" + return None + + +def _first_user_text(payload: Mapping[str, Any]) -> Optional[str]: + messages = payload.get("messages") + if isinstance(messages, list): + for message in messages: + if not isinstance(message, Mapping) or message.get("role") != "user": + continue + text = _text_from_content(message.get("content")) + if text: + return text + + contents = payload.get("contents") + if isinstance(contents, list): + for content in contents: + if not isinstance(content, Mapping) or content.get("role") not in ("user", None): + continue + text = _text_from_parts(content.get("parts")) + if text: + return text + + request_payload = payload.get("request") + if isinstance(request_payload, Mapping): + return _first_user_text(request_payload) + + return None + + +def _text_from_content(content: Any) -> Optional[str]: + if isinstance(content, str): + return content.strip() or None + if isinstance(content, list): + texts = [] + for part in content: + if isinstance(part, str): + texts.append(part) + elif isinstance(part, Mapping): + text = part.get("text") + if not isinstance(text, str): + text = part.get("content") + if isinstance(text, str): + texts.append(text) + joined = " ".join(texts).strip() + return joined or None + return None + + +def _text_from_parts(parts: Any) -> Optional[str]: + if not isinstance(parts, list): + return None + texts = [] + for part in parts: + if isinstance(part, Mapping): + text = part.get("text") + if isinstance(text, str): + texts.append(text) + joined = " ".join(texts).strip() + return joined or None diff --git a/version.txt b/version.txt index c3961c4c1..ad8c1269b 100644 --- a/version.txt +++ b/version.txt @@ -1,4 +1,4 @@ -full_hash=9cb0487442f5fa4f24a50088bfd361f2fdb964f2 -short_hash=9cb0487 -message=Merge pull request #377 from STA1N156/codex/roocode-newapi-compat -date=2026-05-20 19:01:35 +0800 +full_hash=9b8ad0ef5d207083c812d54c5de7f015c8d7b011 +short_hash=9b8ad0e +message=Merge upstream master into cache routing PR [skip ci] +date=2026-05-21 14:57:26 +0800