feat: Antigravity 端点签名迁移至 gemini:chat,流式 usage 提取重构与请求详情自动刷新

- Antigravity 端点签名从 gemini:cli 统一为 gemini:chat,保留向后兼容,含 DB 迁移
- 流式处理中 usage/completion/text 提取抽离为 _update_ctx_from_provider_event,
  支持 envelope 解包后提取,避免格式转换流程中重复计数
- 新增 has_format_conversion 属性,区分真正的格式转换与 envelope rewrite,
  修正 usage 展示层的格式转换标记
- 请求详情抽屉对未完成请求支持自动轮询刷新,关闭时自动停止
- 按次计费样式调整;实际成本仅在倍率非 1.0 时显示;缓存日志降级为 trace
This commit is contained in:
fawney19
2026-02-07 02:59:02 +08:00
parent ea22f07046
commit 62f852b851
24 changed files with 481 additions and 104 deletions

View File

@@ -0,0 +1,116 @@
"""Update Antigravity endpoint signature to gemini:chat
Revision ID: e1b2c3d4f5a6
Revises: b5c6d7e8f9a0
Create Date: 2026-02-06 23:45:00.000000
"""
from __future__ import annotations
from collections.abc import Sequence
from sqlalchemy import text
from alembic import op
# revision identifiers, used by Alembic.
revision: str = "e1b2c3d4f5a6"
down_revision: str | None = "b5c6d7e8f9a0"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
conn = op.get_bind()
# --- provider_endpoints ---
# Update only when there is no conflicting gemini:chat endpoint for the same provider
# (provider_endpoints has a unique constraint on (provider_id, api_format)).
conn.execute(text("""
UPDATE provider_endpoints pe
SET
api_format = 'gemini:chat',
api_family = 'gemini',
endpoint_kind = 'chat'
WHERE pe.api_format = 'gemini:cli'
AND pe.provider_id IN (
SELECT id FROM providers WHERE lower(provider_type) = 'antigravity'
)
AND NOT EXISTS (
SELECT 1 FROM provider_endpoints pe2
WHERE pe2.provider_id = pe.provider_id
AND pe2.api_format = 'gemini:chat'
)
"""))
# Best-effort normalization for already-existing Antigravity gemini:chat endpoints.
conn.execute(text("""
UPDATE provider_endpoints pe
SET
api_family = 'gemini',
endpoint_kind = 'chat'
WHERE pe.api_format = 'gemini:chat'
AND pe.provider_id IN (
SELECT id FROM providers WHERE lower(provider_type) = 'antigravity'
)
"""))
# --- provider_api_keys.api_formats (JSON array) ---
# Replace "gemini:cli" with "gemini:chat" in the JSON array for Antigravity keys.
# Uses text-level replace on the serialized JSON — safe because the value is a
# simple string with no special characters that could cause ambiguous replacements.
conn.execute(text("""
UPDATE provider_api_keys pak
SET api_formats = replace(pak.api_formats::text, '"gemini:cli"', '"gemini:chat"')::json
WHERE pak.provider_id IN (
SELECT id FROM providers WHERE lower(provider_type) = 'antigravity'
)
AND pak.api_formats IS NOT NULL
AND pak.api_formats::text LIKE '%"gemini:cli"%'
"""))
def downgrade() -> None:
conn = op.get_bind()
# --- provider_endpoints ---
conn.execute(text("""
UPDATE provider_endpoints pe
SET
api_format = 'gemini:cli',
api_family = 'gemini',
endpoint_kind = 'cli'
WHERE pe.api_format = 'gemini:chat'
AND pe.provider_id IN (
SELECT id FROM providers WHERE lower(provider_type) = 'antigravity'
)
AND NOT EXISTS (
SELECT 1 FROM provider_endpoints pe2
WHERE pe2.provider_id = pe.provider_id
AND pe2.api_format = 'gemini:cli'
)
"""))
# Best-effort normalization for already-existing Antigravity gemini:cli endpoints.
conn.execute(text("""
UPDATE provider_endpoints pe
SET
api_family = 'gemini',
endpoint_kind = 'cli'
WHERE pe.api_format = 'gemini:cli'
AND pe.provider_id IN (
SELECT id FROM providers WHERE lower(provider_type) = 'antigravity'
)
"""))
# --- provider_api_keys.api_formats (JSON array) ---
conn.execute(text("""
UPDATE provider_api_keys pak
SET api_formats = replace(pak.api_formats::text, '"gemini:chat"', '"gemini:cli"')::json
WHERE pak.provider_id IN (
SELECT id FROM providers WHERE lower(provider_type) = 'antigravity'
)
AND pak.api_formats IS NOT NULL
AND pak.api_formats::text LIKE '%"gemini:chat"%'
"""))

View File

@@ -161,6 +161,7 @@ export interface RequestDetail {
request_type: string
is_stream: boolean
status_code: number
status?: string // pending, streaming, completed, failed, cancelled
error_message?: string
response_time_ms: number
created_at: string

View File

@@ -184,7 +184,7 @@ async function handleTestModel(modelName: string) {
provider_id: props.providerId,
model_name: modelName,
api_key_id: props.keyId,
api_format: 'gemini:cli',
api_format: 'gemini:chat',
message: 'hello',
})

View File

@@ -642,19 +642,28 @@ const navigateGroup = (direction: number) => {
}
// 加载请求追踪数据
const loadTrace = async () => {
const isSilentRefresh = ref(false)
const loadTrace = async (silent = false) => {
if (!props.requestId) return
loading.value = true
isSilentRefresh.value = silent
if (!silent) {
loading.value = true
}
error.value = null
try {
trace.value = await requestTraceApi.getRequestTrace(props.requestId)
} catch (err: any) {
error.value = err.response?.data?.detail || err.message || '加载失败'
if (!silent) {
error.value = err.response?.data?.detail || err.message || '加载失败'
}
log.error('加载请求追踪失败:', err)
} finally {
loading.value = false
if (!silent) {
loading.value = false
}
}
}
@@ -662,6 +671,12 @@ const loadTrace = async () => {
watch(groupedTimeline, (newGroups) => {
if (!newGroups || newGroups.length === 0) return
// 静默刷新时不重置选中状态
if (isSilentRefresh.value) {
isSilentRefresh.value = false
return
}
// 查找成功的组
const successIdx = newGroups.findIndex(g => g.primaryStatus === 'success')
if (successIdx >= 0) {
@@ -712,6 +727,8 @@ watch(() => props.requestId, () => {
loadTrace()
}, { immediate: true })
defineExpose({ refresh: () => loadTrace(true) })
// 格式化时间(详细)
const formatTime = (dateStr: string) => {
const date = new Date(dateStr)

View File

@@ -66,13 +66,13 @@
variant="ghost"
size="icon"
class="h-8 w-8"
:disabled="loading"
title="刷新"
:disabled="loading && !autoRefreshing"
:title="autoRefreshing ? '停止自动刷新' : '刷新'"
@click="refreshDetail"
>
<RefreshCw
class="w-4 h-4"
:class="{ 'animate-spin': loading }"
:class="{ 'animate-spin': loading || autoRefreshing, 'text-primary': autoRefreshing }"
/>
</Button>
<Button
@@ -275,25 +275,27 @@
</div>
</div>
<!-- ========== 3. 按次计费独立隔离 ========== -->
<!-- ========== 3. 按次计费 ========== -->
<div
v-if="perRequestCost > 0 && !detail.video_billing"
class="rounded-lg p-3 bg-amber-500/5 border border-amber-500/30 mb-3"
class="space-y-2 mb-3"
>
<div class="flex items-center justify-between text-xs mb-2">
<span class="font-medium text-amber-600 dark:text-amber-400">按次计费</span>
<span
v-if="detail.price_per_request"
class="text-muted-foreground"
>
单价 ${{ detail.price_per_request.toFixed(6) }}/
</span>
<div class="flex items-center justify-between text-xs">
<span class="font-medium text-foreground">按次计费</span>
</div>
<div class="flex items-center">
<div class="flex items-center flex-1">
<span class="text-xs text-muted-foreground w-[56px]">请求次数</span>
<span class="text-sm font-semibold font-mono flex-1 text-center">1</span>
<span class="text-xs font-mono font-medium">${{ perRequestCost.toFixed(6) }}</span>
<div class="rounded-lg p-3 bg-primary/5 border border-primary/30 space-y-2">
<div
v-if="detail.price_per_request"
class="flex items-center justify-end text-xs"
>
<span class="text-muted-foreground">${{ detail.price_per_request.toFixed(6) }}/</span>
</div>
<div class="flex items-center">
<div class="flex items-center flex-1">
<span class="text-xs text-muted-foreground w-[56px]">请求次数</span>
<span class="text-sm font-semibold font-mono flex-1 text-center">1</span>
<span class="text-xs font-mono font-medium">${{ perRequestCost.toFixed(6) }}</span>
</div>
</div>
</div>
</div>
@@ -361,6 +363,7 @@
<!-- 请求链路追踪卡片 -->
<div v-if="detail.request_id || detail.id">
<HorizontalRequestTimeline
ref="timelineRef"
:request-id="detail.request_id || detail.id"
:override-status-code="detail.status_code"
/>
@@ -590,7 +593,7 @@
</template>
<script setup lang="ts">
import { ref, watch, computed } from 'vue'
import { ref, watch, computed, onBeforeUnmount } from 'vue'
import Button from '@/components/ui/button.vue'
import { useEscapeKey } from '@/composables/useEscapeKey'
import { useClipboard } from '@/composables/useClipboard'
@@ -631,6 +634,7 @@ const emit = defineEmits<{
const loading = ref(false)
const error = ref<string | null>(null)
const detail = ref<RequestDetail | null>(null)
const timelineRef = ref<InstanceType<typeof HorizontalRequestTimeline> | null>(null)
const activeTab = ref('request-body')
const copiedStates = ref<Record<string, boolean>>({})
const viewMode = ref<'compare' | 'formatted' | 'raw'>('formatted')
@@ -645,6 +649,8 @@ const historicalPricing = ref<{
cache_read_price: string
request_price: string
} | null>(null)
const autoRefreshTimer = ref<ReturnType<typeof setInterval> | null>(null)
const autoRefreshing = ref(false)
// 监听标签页切换
watch(activeTab, (newTab) => {
@@ -870,26 +876,38 @@ watch(() => props.requestId, async (newId) => {
watch(() => props.isOpen, async (isOpen) => {
if (isOpen && props.requestId) {
await loadDetail(props.requestId)
} else if (!isOpen) {
stopAutoRefresh()
}
})
async function loadDetail(id: string) {
loading.value = true
async function loadDetail(id: string, silent = false) {
if (!silent) {
loading.value = true
historicalPricing.value = null
}
error.value = null
historicalPricing.value = null
try {
detail.value = await dashboardApi.getRequestDetail(id)
// 默认显示有内容的第一个可见 tab
const visibleTabNames = visibleTabs.value.map(t => t.name)
if (detail.value.request_body && visibleTabNames.includes('request-body')) {
activeTab.value = 'request-body'
} else if (detail.value.response_body && visibleTabNames.includes('response-body')) {
activeTab.value = 'response-body'
} else if (visibleTabNames.length > 0) {
activeTab.value = visibleTabNames[0]
// 首次加载时选择默认 tab
if (!silent) {
const visibleTabNames = visibleTabs.value.map(t => t.name)
if (detail.value.request_body && visibleTabNames.includes('request-body')) {
activeTab.value = 'request-body'
} else if (detail.value.response_body && visibleTabNames.includes('response-body')) {
activeTab.value = 'response-body'
} else if (visibleTabNames.length > 0) {
activeTab.value = visibleTabNames[0]
}
}
// 根据数据可用性自动选择请求头数据源
// provider_request_headers 在 streaming 完成后才写入pending/streaming 期间为空
const hasProviderReqHeaders = detail.value.provider_request_headers &&
Object.keys(detail.value.provider_request_headers).length > 0
dataSource.value = hasProviderReqHeaders ? 'provider' : 'client'
// 使用请求记录中保存的历史价格
if (detail.value.input_price_per_1m || detail.value.output_price_per_1m || detail.value.price_per_request) {
historicalPricing.value = {
@@ -900,24 +918,81 @@ async function loadDetail(id: string) {
request_price: detail.value.price_per_request ? detail.value.price_per_request.toFixed(4) : 'N/A'
}
}
// 静默刷新时同步刷新链路追踪
if (silent) {
timelineRef.value?.refresh()
}
} catch (err) {
log.error('Failed to load request detail:', err)
error.value = '加载请求详情失败'
if (!silent) {
error.value = '加载请求详情失败'
}
} finally {
loading.value = false
if (!silent) {
loading.value = false
}
}
}
function handleClose() {
stopAutoRefresh()
emit('close')
}
async function refreshDetail() {
if (props.requestId) {
await loadDetail(props.requestId)
}
function isRequestCompleted(): boolean {
if (!detail.value?.status) return true
return !['pending', 'streaming'].includes(detail.value.status)
}
function stopAutoRefresh() {
if (autoRefreshTimer.value) {
clearInterval(autoRefreshTimer.value)
autoRefreshTimer.value = null
}
autoRefreshing.value = false
}
async function refreshDetail() {
if (!props.requestId) return
// 已完成:单次静默刷新
if (isRequestCompleted()) {
await loadDetail(props.requestId, true)
return
}
// 未完成:如果已在自动刷新则停止,否则启动
if (autoRefreshing.value) {
stopAutoRefresh()
return
}
autoRefreshing.value = true
await loadDetail(props.requestId, true)
// 加载后可能已经完成了
if (isRequestCompleted()) {
stopAutoRefresh()
return
}
autoRefreshTimer.value = setInterval(async () => {
if (!props.requestId || !props.isOpen) {
stopAutoRefresh()
return
}
await loadDetail(props.requestId, true)
if (isRequestCompleted()) {
stopAutoRefresh()
}
}, 1000)
}
onBeforeUnmount(() => {
stopAutoRefresh()
})
function formatDateTime(dateStr: string | null | undefined): string {
if (!dateStr) return 'N/A'
const date = new Date(dateStr)

View File

@@ -193,7 +193,7 @@
<div class="flex flex-col items-end flex-shrink-0">
<span class="text-xs text-primary font-medium">{{ formatCurrency(record.cost || 0) }}</span>
<span
v-if="showActualCost && record.actual_cost !== undefined"
v-if="showActualCost && record.actual_cost !== undefined && record.rate_multiplier && record.rate_multiplier !== 1.0"
class="text-[10px] text-muted-foreground"
>{{ formatCurrency(record.actual_cost) }}</span>
</div>
@@ -562,7 +562,7 @@
<div class="flex flex-col items-end text-xs gap-0.5">
<span class="text-primary font-medium">{{ formatCurrency(record.cost || 0) }}</span>
<span
v-if="showActualCost && record.actual_cost !== undefined"
v-if="showActualCost && record.actual_cost !== undefined && record.rate_multiplier && record.rate_multiplier !== 1.0"
class="text-muted-foreground"
>
{{ formatCurrency(record.actual_cost) }}

View File

@@ -1009,7 +1009,7 @@ class AdminRefreshProviderQuotaAdapter(AdminApiAdapter):
# 获取端点:
# - Codex: openai:cli
# - Antigravity: gemini:cli(用于触发 oauth 刷新 + 提供 auth_config.project_id
# - Antigravity: gemini:chat(用于触发 oauth 刷新 + 提供 auth_config.project_id
endpoint = None
if provider_type == ProviderType.CODEX:
for ep in provider.endpoints:
@@ -1019,12 +1019,16 @@ class AdminRefreshProviderQuotaAdapter(AdminApiAdapter):
if not endpoint:
raise InvalidRequestException("找不到有效的 openai:cli 端点")
else:
for ep in provider.endpoints:
if ep.api_format == "gemini:cli" and ep.is_active:
endpoint = ep
# Prefer the new signature, but keep backward-compat with existing DB rows.
for sig in ("gemini:chat", "gemini:cli"):
for ep in provider.endpoints:
if ep.api_format == sig and ep.is_active:
endpoint = ep
break
if endpoint is not None:
break
if not endpoint:
raise InvalidRequestException("找不到有效的 gemini:cli 端点")
raise InvalidRequestException("找不到有效的 gemini:chat/gemini:cli 端点")
results: list[dict] = []
success_count = 0

View File

@@ -70,7 +70,7 @@ async def _resolve_key_auth(
auth_config: dict[str, Any] | None = None
if auth_type == "oauth":
endpoint_api_format = "gemini:cli" if provider_type == ProviderType.ANTIGRAVITY else None
endpoint_api_format = "gemini:chat" if provider_type == ProviderType.ANTIGRAVITY else None
try:
resolved = await resolve_oauth_access_token(
key_id=str(api_key.id),

View File

@@ -1181,6 +1181,7 @@ class AdminUsageDetailAdapter(AdminApiAdapter):
"is_stream": usage_record.is_stream,
"status_code": usage_record.status_code,
"error_message": usage_record.error_message,
"status": usage_record.status,
"response_time_ms": usage_record.response_time_ms,
"first_byte_time_ms": usage_record.first_byte_time_ms, # 首字时间 (TTFB)
"created_at": usage_record.created_at.isoformat() if usage_record.created_at else None,

View File

@@ -571,7 +571,7 @@ class BaseMessageHandler:
api_format = ctx.api_format
# 格式转换追踪
endpoint_api_format = ctx.provider_api_format or None
has_format_conversion = ctx.needs_conversion
has_format_conversion = ctx.has_format_conversion
# 如果 provider 为空,记录警告(不应该发生,但用于调试)
if not provider:

View File

@@ -40,7 +40,7 @@ from src.api.handlers.base.base_handler import (
from src.api.handlers.base.parsers import get_parser_for_format
from src.api.handlers.base.request_builder import PassthroughRequestBuilder, get_provider_auth
from src.api.handlers.base.response_parser import ResponseParser
from src.api.handlers.base.stream_context import StreamContext
from src.api.handlers.base.stream_context import StreamContext, is_format_converted
from src.api.handlers.base.stream_processor import StreamProcessor
from src.api.handlers.base.stream_telemetry import StreamTelemetryRecorder
from src.api.handlers.base.upstream_stream_bridge import (
@@ -1322,7 +1322,7 @@ class ChatHandlerBase(BaseMessageHandler, ABC):
client_response_headers=client_response_headers,
# 格式转换追踪
endpoint_api_format=ctx.provider_api_format or None,
has_format_conversion=ctx.needs_conversion,
has_format_conversion=ctx.has_format_conversion,
target_model=ctx.mapped_model,
)
@@ -1367,7 +1367,7 @@ class ChatHandlerBase(BaseMessageHandler, ABC):
provider_request_body: dict[str, Any] | None = None
provider_api_format_for_error: str | None = None
client_api_format_for_error: str | None = None
needs_conversion_for_error: bool = False
needs_conversion_for_error: bool = False # 用于构建错误 payload含 envelope rewrite
provider_id: str | None = None # Provider ID用于失败记录
endpoint_id: str | None = None # Endpoint ID用于失败记录
key_id: str | None = None # Key ID用于失败记录
@@ -1795,7 +1795,9 @@ class ChatHandlerBase(BaseMessageHandler, ABC):
api_format=api_format,
# 格式转换追踪
endpoint_api_format=provider_api_format_for_error or None,
has_format_conversion=needs_conversion_for_error,
has_format_conversion=is_format_converted(
provider_api_format_for_error, client_api_format_for_error
),
provider_id=provider_id,
provider_endpoint_id=endpoint_id,
provider_api_key_id=key_id,
@@ -1865,7 +1867,9 @@ class ChatHandlerBase(BaseMessageHandler, ABC):
client_response_headers={"content-type": "application/json"},
# 格式转换追踪
endpoint_api_format=provider_api_format_for_error or None,
has_format_conversion=needs_conversion_for_error,
has_format_conversion=is_format_converted(
provider_api_format_for_error, client_api_format_for_error
),
target_model=mapped_model_result,
request_metadata=request_metadata,
)
@@ -1916,7 +1920,9 @@ class ChatHandlerBase(BaseMessageHandler, ABC):
client_response_headers={"content-type": "application/json"},
# 格式转换追踪
endpoint_api_format=provider_api_format_for_error or None,
has_format_conversion=needs_conversion_for_error,
has_format_conversion=is_format_converted(
provider_api_format_for_error, client_api_format_for_error
),
# 模型映射信息
target_model=mapped_model_result,
request_metadata=request_metadata,

View File

@@ -43,7 +43,7 @@ from src.api.handlers.base.request_builder import PassthroughRequestBuilder, get
from src.api.handlers.base.response_parser import (
ResponseParser,
)
from src.api.handlers.base.stream_context import StreamContext
from src.api.handlers.base.stream_context import StreamContext, is_format_converted
from src.api.handlers.base.upstream_stream_bridge import (
aggregate_upstream_stream_to_internal_response,
)
@@ -2459,7 +2459,7 @@ class CliMessageHandlerBase(BaseMessageHandler):
response_headers=ctx.response_headers,
client_response_headers=client_response_headers,
endpoint_api_format=ctx.provider_api_format or None,
has_format_conversion=ctx.needs_conversion,
has_format_conversion=ctx.has_format_conversion,
target_model=ctx.mapped_model,
request_metadata=request_metadata,
)
@@ -2492,7 +2492,7 @@ class CliMessageHandlerBase(BaseMessageHandler):
client_response_headers=client_response_headers,
# 格式转换追踪
endpoint_api_format=ctx.provider_api_format or None,
has_format_conversion=ctx.needs_conversion,
has_format_conversion=ctx.has_format_conversion,
# 模型映射信息
target_model=ctx.mapped_model,
request_metadata=request_metadata,
@@ -2552,7 +2552,7 @@ class CliMessageHandlerBase(BaseMessageHandler):
api_format=ctx.api_format,
# 格式转换追踪
endpoint_api_format=ctx.provider_api_format or None,
has_format_conversion=ctx.needs_conversion,
has_format_conversion=ctx.has_format_conversion,
# Provider 侧追踪信息(用于记录真实成本)
provider_id=ctx.provider_id,
provider_endpoint_id=ctx.endpoint_id,
@@ -2709,7 +2709,7 @@ class CliMessageHandlerBase(BaseMessageHandler):
client_response_headers=client_response_headers,
# 格式转换追踪
endpoint_api_format=ctx.provider_api_format or None,
has_format_conversion=ctx.needs_conversion,
has_format_conversion=ctx.has_format_conversion,
# 模型映射信息
target_model=ctx.mapped_model,
request_metadata=request_metadata,
@@ -3170,7 +3170,7 @@ class CliMessageHandlerBase(BaseMessageHandler):
api_format=api_format,
# 格式转换追踪
endpoint_api_format=provider_api_format or None,
has_format_conversion=needs_conversion,
has_format_conversion=is_format_converted(provider_api_format, str(api_format)),
# Provider 侧追踪信息(用于记录真实成本)
provider_id=provider_id,
provider_endpoint_id=endpoint_id,
@@ -3249,7 +3249,7 @@ class CliMessageHandlerBase(BaseMessageHandler):
client_response_headers={"content-type": "application/json"},
# 格式转换追踪
endpoint_api_format=provider_api_format or None,
has_format_conversion=needs_conversion,
has_format_conversion=is_format_converted(provider_api_format, str(api_format)),
# 模型映射信息
target_model=mapped_model_result,
request_metadata=request_metadata,
@@ -3364,7 +3364,7 @@ class CliMessageHandlerBase(BaseMessageHandler):
first_byte_time_ms=ctx.first_byte_time_ms,
api_format=ctx.api_format,
endpoint_api_format=ctx.provider_api_format or None,
has_format_conversion=ctx.needs_conversion,
has_format_conversion=ctx.has_format_conversion,
)
except Exception as e:
logger.warning(f"[{self.request_id}] 同步更新 streaming 状态失败: {e}")

View File

@@ -18,6 +18,18 @@ if TYPE_CHECKING:
from src.core.api_format.conversion.stream_state import StreamState
def is_format_converted(
provider_api_format: str | None,
client_api_format: str | None,
) -> bool:
"""client 与 provider 的 api_format 是否真正不同(用于 usage 展示层)"""
return bool(
provider_api_format
and client_api_format
and provider_api_format.strip().lower() != client_api_format.strip().lower()
)
@dataclass
class StreamContext:
"""
@@ -234,6 +246,15 @@ class StreamContext:
if self.first_byte_time_ms is None:
self.first_byte_time_ms = int((time.time() - start_time) * 1000)
@property
def has_format_conversion(self) -> bool:
"""是否发生了真正的格式转换client 和 provider 的 api_format 不同)
区别于 needs_conversion后者包含 envelope rewrite如 Antigravity v1internal
不代表客户端与上游的数据格式真正不同。此属性用于 usage 展示层。
"""
return is_format_converted(self.provider_api_format, self.client_api_format)
def is_success(self) -> bool:
"""检查请求是否成功"""
return self.status_code < 400

View File

@@ -108,6 +108,115 @@ class StreamProcessor:
pass
return self.default_parser
@staticmethod
def _maybe_mark_gemini_completion(ctx: StreamContext, data: dict[str, Any]) -> None:
"""Gemini: mark completion based on candidates[].finishReason."""
candidates = data.get("candidates")
if not isinstance(candidates, list) or not candidates:
return
for candidate in candidates:
if not isinstance(candidate, dict):
continue
finish_reason = candidate.get("finishReason")
if finish_reason is None:
continue
# UNSPECIFIED is the only clear "not done" sentinel across Gemini variants.
if str(finish_reason) != "FINISH_REASON_UNSPECIFIED":
ctx.has_completion = True
return
@staticmethod
def _extract_antigravity_usage_from_gemini_event(data: dict[str, Any]) -> dict[str, int] | None:
"""Antigravity: lenient Gemini usage extraction (totalTokenCount may be missing)."""
usage_metadata = data.get("usageMetadata", {})
if not isinstance(usage_metadata, dict) or not usage_metadata:
return None
def _as_int(v: Any) -> int:
try:
return int(v or 0)
except Exception:
return 0
prompt = _as_int(usage_metadata.get("promptTokenCount"))
cached = _as_int(usage_metadata.get("cachedContentTokenCount"))
candidates = _as_int(usage_metadata.get("candidatesTokenCount"))
thoughts = _as_int(usage_metadata.get("thoughtsTokenCount"))
# Align with Gemini billing convention: input_tokens includes cached content.
return {
"input_tokens": max(0, prompt),
"output_tokens": max(0, candidates + thoughts),
"cache_creation_tokens": 0,
"cache_read_tokens": max(0, cached),
}
def _unwrap_provider_envelope(self, ctx: StreamContext, data: dict[str, Any]) -> dict[str, Any]:
behavior = get_provider_behavior(
provider_type=str(getattr(ctx, "provider_type", "") or ""),
endpoint_sig=str(getattr(ctx, "provider_api_format", "") or ""),
)
envelope = behavior.envelope
if not envelope:
return data
try:
unwrapped = envelope.unwrap_response(data)
envelope.postprocess_unwrapped_response(
model=str(getattr(ctx, "model", "") or ""),
data=unwrapped,
)
return unwrapped if isinstance(unwrapped, dict) else data
except Exception:
return data
def _update_ctx_from_provider_event(
self,
ctx: StreamContext,
data: dict[str, Any],
*,
already_unwrapped: bool = False,
) -> None:
# Unwrap provider-specific envelopes (e.g. Antigravity v1internal wrapper)
if not already_unwrapped:
data = self._unwrap_provider_envelope(ctx, data)
parser = self.get_parser_for_provider(ctx)
# Provider usage extraction (best-effort)
provider_type = str(getattr(ctx, "provider_type", "") or "").lower()
provider_format = str(getattr(ctx, "provider_api_format", "") or "").strip().lower()
usage: dict[str, int] | None = None
if provider_type == "antigravity" and provider_format.startswith("gemini:"):
usage = self._extract_antigravity_usage_from_gemini_event(data)
if usage is None:
try:
usage = parser.extract_usage_from_response(data)
except Exception:
usage = None
if usage:
ctx.update_usage(
input_tokens=usage.get("input_tokens"),
output_tokens=usage.get("output_tokens"),
cached_tokens=usage.get("cache_read_tokens"),
cache_creation_tokens=usage.get("cache_creation_tokens"),
)
# Provider completion detection (Gemini doesn't emit response.completed).
if provider_format.startswith("gemini:"):
self._maybe_mark_gemini_completion(ctx, data)
# Provider text extraction (optional)
if self.collect_text:
try:
text = parser.extract_text_content(data)
except Exception:
text = ""
if text:
ctx.append_text(text)
def handle_sse_event(
self,
ctx: StreamContext,
@@ -115,6 +224,7 @@ class StreamProcessor:
data_str: str,
*,
skip_record: bool = False,
skip_ctx_update: bool = False,
) -> None:
"""
处理单个 SSE 事件
@@ -126,6 +236,7 @@ class StreamProcessor:
event_name: 事件名称
data_str: 事件数据字符串
skip_record: 是否跳过记录到 parsed_chunks当需要格式转换时应为 True
skip_ctx_update: 跳过 usage/completion/text 提取(由调用方统一处理时使用)
"""
if not data_str:
return
@@ -142,31 +253,17 @@ class StreamProcessor:
if not isinstance(data, dict):
return
# Update usage/completion/text from provider event (envelope-aware).
# 在 needs_conversion 正常流中由 _emit_converted_line 统一处理,此处跳过以避免重复。
if not skip_ctx_update:
self._update_ctx_from_provider_event(ctx, data)
# 统计数据事件数量(当需要格式转换时跳过,由 _emit_converted_line 统计/记录转换后的数据)
if not skip_record:
ctx.data_count += 1
if ctx.record_parsed_chunks:
ctx.parsed_chunks.append(data)
# 根据 Provider 格式选择解析器
parser = self.get_parser_for_provider(ctx)
# 使用解析器提取 usage
usage = parser.extract_usage_from_response(data)
if usage:
ctx.update_usage(
input_tokens=usage.get("input_tokens"),
output_tokens=usage.get("output_tokens"),
cached_tokens=usage.get("cache_read_tokens"),
cache_creation_tokens=usage.get("cache_creation_tokens"),
)
# 提取文本
if self.collect_text:
text = parser.extract_text_content(data)
if text:
ctx.append_text(text)
# 检查完成
event_type = event_name or data.get("type", "")
if event_type in ("response.completed", "message_stop"):
@@ -476,14 +573,31 @@ class StreamProcessor:
self.on_streaming_start()
streaming_started = True
def _process_line_with_perf(line: str, *, skip_record: bool = False) -> None:
def _process_line_with_perf(
line: str,
*,
skip_record: bool = False,
skip_ctx_update: bool = False,
) -> None:
nonlocal parse_time
if perf_capture:
t0 = time.perf_counter()
self._process_line(ctx, sse_parser, line, skip_record=skip_record)
self._process_line(
ctx,
sse_parser,
line,
skip_record=skip_record,
skip_ctx_update=skip_ctx_update,
)
parse_time += time.perf_counter() - t0
return
self._process_line(ctx, sse_parser, line, skip_record=skip_record)
self._process_line(
ctx,
sse_parser,
line,
skip_record=skip_record,
skip_ctx_update=skip_ctx_update,
)
def _build_stream_error_payload(message: str) -> dict:
if client_family == "openai":
@@ -589,6 +703,14 @@ class StreamProcessor:
data=data_obj,
)
# Update usage/completion/text based on the unwrapped provider event.
if isinstance(data_obj, dict):
self._update_ctx_from_provider_event(
ctx,
data_obj,
already_unwrapped=True,
)
try:
converted_events = registry.convert_stream_chunk(
data_obj,
@@ -648,7 +770,9 @@ class StreamProcessor:
if line:
# 需要格式转换时,跳过记录原始数据(由 _emit_converted_line 记录转换后的数据)
_process_line_with_perf(line, skip_record=True)
_process_line_with_perf(
line, skip_record=True, skip_ctx_update=True
)
normalized_line = line.rstrip("\r\n") if line else ""
out_chunks = _emit_converted_line(normalized_line)
if not out_chunks:
@@ -878,6 +1002,7 @@ class StreamProcessor:
line: str,
*,
skip_record: bool = False,
skip_ctx_update: bool = False,
) -> None:
"""
处理单行数据
@@ -887,6 +1012,7 @@ class StreamProcessor:
sse_parser: SSE 解析器
line: 原始行数据
skip_record: 是否跳过记录到 parsed_chunks当需要格式转换时应为 True
skip_ctx_update: 跳过 usage/completion/text 提取(由调用方统一处理时使用)
"""
# SSEEventParser 以"去掉换行符"的单行文本作为输入;这里统一剔除 CR/LF
# 避免把空行误判成 "\n" 并导致事件边界解析错误。
@@ -898,7 +1024,11 @@ class StreamProcessor:
for event in events:
self.handle_sse_event(
ctx, event.get("event"), event.get("data") or "", skip_record=skip_record
ctx,
event.get("event"),
event.get("data") or "",
skip_record=skip_record,
skip_ctx_update=skip_ctx_update,
)
async def create_monitored_stream(

View File

@@ -227,7 +227,7 @@ class StreamTelemetryRecorder:
request_type="chat",
metadata=metadata,
endpoint_api_format=ctx.provider_api_format,
has_format_conversion=ctx.needs_conversion,
has_format_conversion=ctx.has_format_conversion,
)
logger.debug(f"{self.format_id} 流式响应完成")
@@ -273,7 +273,7 @@ class StreamTelemetryRecorder:
request_type="chat",
metadata=metadata,
endpoint_api_format=ctx.provider_api_format,
has_format_conversion=ctx.needs_conversion,
has_format_conversion=ctx.has_format_conversion,
)
logger.debug(f"{self.format_id} 流式响应中断")
@@ -320,7 +320,7 @@ class StreamTelemetryRecorder:
request_type="chat",
metadata=metadata,
endpoint_api_format=ctx.provider_api_format,
has_format_conversion=ctx.needs_conversion,
has_format_conversion=ctx.has_format_conversion,
)
logger.debug(f"{self.format_id} 流式响应被客户端取消")

View File

@@ -106,7 +106,7 @@ FIXED_PROVIDERS: dict[ProviderType, FixedProviderTemplate] = {
provider_type=ProviderType.ANTIGRAVITY,
display_name="Antigravity",
api_base_url=ANTIGRAVITY_PROD_URL,
endpoint_signatures=["gemini:cli"],
endpoint_signatures=["gemini:chat"],
oauth=FixedProviderOAuth(
authorize_url="https://accounts.google.com/o/oauth2/v2/auth",
token_url="https://oauth2.googleapis.com/token",

View File

@@ -303,7 +303,9 @@ class ModelFetchScheduler:
# Use request_builder's lazy refresh logic and persist refreshed token back to DB.
# Endpoint signature is only used for tracing/debug; auth logic doesn't depend on it.
endpoint_api_format = (
"gemini:cli" if prepared.provider_type.lower() == ProviderType.ANTIGRAVITY else None
"gemini:chat"
if prepared.provider_type.lower() == ProviderType.ANTIGRAVITY
else None
)
try:
resolved = await resolve_oauth_access_token(

View File

@@ -1,6 +1,6 @@
"""Antigravity v1internal request/response envelope helpers.
Antigravity reuses the `gemini:cli` endpoint signature but wraps the actual
Antigravity reuses the `gemini:chat` endpoint signature but wraps the actual
wire format:
- Request: V1InternalRequest (top-level metadata + nested GeminiRequest)
- Response: V1InternalResponse (top-level responseId + nested GeminiResponse)

View File

@@ -249,7 +249,7 @@ async def fetch_models_antigravity(
"id": model_id,
"owned_by": "antigravity",
"display_name": display_name,
"api_format": "gemini:cli",
"api_format": "gemini:chat",
}
)
@@ -324,10 +324,14 @@ def register_all() -> None:
from src.services.provider.transport import register_transport_hook
# Envelope
register_envelope("antigravity", "gemini:chat", antigravity_v1internal_envelope)
# Backward compat: allow existing endpoints that still use the old signature.
register_envelope("antigravity", "gemini:cli", antigravity_v1internal_envelope)
register_envelope("antigravity", "", antigravity_v1internal_envelope)
# Transport
register_transport_hook("antigravity", "gemini:chat", build_antigravity_url)
# Backward compat: allow existing endpoints that still use the old signature.
register_transport_hook("antigravity", "gemini:cli", build_antigravity_url)
# Auth

View File

@@ -106,7 +106,7 @@ def cache_result(
if cached:
try:
result = json.loads(cached)
logger.debug(f"缓存命中: {cache_key}")
logger.trace(f"缓存命中: {cache_key}")
return result
except json.JSONDecodeError as e:
logger.warning(f"缓存解析失败,删除损坏缓存: {cache_key}, 错误: {e}")
@@ -123,7 +123,7 @@ def cache_result(
await redis_client.setex(
cache_key, ttl, json.dumps(result, ensure_ascii=False, default=str)
)
logger.debug(f"缓存已保存: {cache_key}, TTL: {ttl}s")
logger.trace(f"缓存已保存: {cache_key}, TTL: {ttl}s")
except Exception as e:
logger.warning(f"保存缓存失败: {e}")

View File

@@ -62,7 +62,7 @@ def test_convert_sse_line_unwraps_before_convert_stream_chunk() -> None:
ctx = StreamContext(model="gemini-2.0-flash", api_format="gemini:cli")
ctx.provider_type = "antigravity"
ctx.provider_api_format = "gemini:cli"
ctx.provider_api_format = "gemini:chat"
ctx.client_api_format = "gemini:cli"
v1_line = (

View File

@@ -32,7 +32,7 @@ def test_antigravity_converts_claude_thinking_block_to_gemini_thought_part_prefe
}
registry = get_format_converter_registry()
out = registry.convert_request(req, "claude:chat", "gemini:cli", target_variant="antigravity")
out = registry.convert_request(req, "claude:chat", "gemini:chat", target_variant="antigravity")
assert isinstance(out.get("contents"), list)
model_turn = out["contents"][1]
@@ -63,7 +63,7 @@ def test_antigravity_uses_dummy_signature_for_gemini_when_missing() -> None:
}
registry = get_format_converter_registry()
out = registry.convert_request(req, "claude:chat", "gemini:cli", target_variant="antigravity")
out = registry.convert_request(req, "claude:chat", "gemini:chat", target_variant="antigravity")
parts = out["contents"][1]["parts"]
assert parts[0]["thought"] is True
@@ -91,7 +91,7 @@ def test_antigravity_drops_unsigned_thinking_for_non_gemini_models() -> None:
}
registry = get_format_converter_registry()
out = registry.convert_request(req, "claude:chat", "gemini:cli", target_variant="antigravity")
out = registry.convert_request(req, "claude:chat", "gemini:chat", target_variant="antigravity")
parts = out["contents"][1]["parts"]
assert all(p.get("thought") is not True for p in parts)
@@ -111,7 +111,7 @@ def test_antigravity_inserts_dummy_thought_for_last_assistant_when_thinking_enab
}
registry = get_format_converter_registry()
out = registry.convert_request(req, "claude:chat", "gemini:cli", target_variant="antigravity")
out = registry.convert_request(req, "claude:chat", "gemini:chat", target_variant="antigravity")
parts = out["contents"][1]["parts"]
assert parts[0]["thought"] is True

View File

@@ -19,7 +19,7 @@ class _DummyEndpoint:
def test_antigravity_uses_v1internal_path_and_sets_contextvar() -> None:
endpoint = _DummyEndpoint(
base_url="https://ignored.example.com",
api_format="gemini:cli",
api_format="gemini:chat",
provider=SimpleNamespace(provider_type="antigravity"),
)

View File

@@ -20,7 +20,7 @@ async def test_get_provider_auth_oauth_returns_decrypted_auth_config() -> None:
"project_id": "project-1",
}
endpoint = SimpleNamespace(api_format="gemini:cli")
endpoint = SimpleNamespace(api_format="gemini:chat")
key = SimpleNamespace(
id="k1",
auth_type="oauth",