mirror of
https://github.com/fawney19/Aether.git
synced 2026-09-02 17:30:23 +08:00
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:
@@ -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"%'
|
||||||
|
"""))
|
||||||
@@ -161,6 +161,7 @@ export interface RequestDetail {
|
|||||||
request_type: string
|
request_type: string
|
||||||
is_stream: boolean
|
is_stream: boolean
|
||||||
status_code: number
|
status_code: number
|
||||||
|
status?: string // pending, streaming, completed, failed, cancelled
|
||||||
error_message?: string
|
error_message?: string
|
||||||
response_time_ms: number
|
response_time_ms: number
|
||||||
created_at: string
|
created_at: string
|
||||||
|
|||||||
@@ -184,7 +184,7 @@ async function handleTestModel(modelName: string) {
|
|||||||
provider_id: props.providerId,
|
provider_id: props.providerId,
|
||||||
model_name: modelName,
|
model_name: modelName,
|
||||||
api_key_id: props.keyId,
|
api_key_id: props.keyId,
|
||||||
api_format: 'gemini:cli',
|
api_format: 'gemini:chat',
|
||||||
message: 'hello',
|
message: 'hello',
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -642,19 +642,28 @@ const navigateGroup = (direction: number) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 加载请求追踪数据
|
// 加载请求追踪数据
|
||||||
const loadTrace = async () => {
|
const isSilentRefresh = ref(false)
|
||||||
|
const loadTrace = async (silent = false) => {
|
||||||
if (!props.requestId) return
|
if (!props.requestId) return
|
||||||
|
|
||||||
loading.value = true
|
isSilentRefresh.value = silent
|
||||||
|
|
||||||
|
if (!silent) {
|
||||||
|
loading.value = true
|
||||||
|
}
|
||||||
error.value = null
|
error.value = null
|
||||||
|
|
||||||
try {
|
try {
|
||||||
trace.value = await requestTraceApi.getRequestTrace(props.requestId)
|
trace.value = await requestTraceApi.getRequestTrace(props.requestId)
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
error.value = err.response?.data?.detail || err.message || '加载失败'
|
if (!silent) {
|
||||||
|
error.value = err.response?.data?.detail || err.message || '加载失败'
|
||||||
|
}
|
||||||
log.error('加载请求追踪失败:', err)
|
log.error('加载请求追踪失败:', err)
|
||||||
} finally {
|
} finally {
|
||||||
loading.value = false
|
if (!silent) {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -662,6 +671,12 @@ const loadTrace = async () => {
|
|||||||
watch(groupedTimeline, (newGroups) => {
|
watch(groupedTimeline, (newGroups) => {
|
||||||
if (!newGroups || newGroups.length === 0) return
|
if (!newGroups || newGroups.length === 0) return
|
||||||
|
|
||||||
|
// 静默刷新时不重置选中状态
|
||||||
|
if (isSilentRefresh.value) {
|
||||||
|
isSilentRefresh.value = false
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
// 查找成功的组
|
// 查找成功的组
|
||||||
const successIdx = newGroups.findIndex(g => g.primaryStatus === 'success')
|
const successIdx = newGroups.findIndex(g => g.primaryStatus === 'success')
|
||||||
if (successIdx >= 0) {
|
if (successIdx >= 0) {
|
||||||
@@ -712,6 +727,8 @@ watch(() => props.requestId, () => {
|
|||||||
loadTrace()
|
loadTrace()
|
||||||
}, { immediate: true })
|
}, { immediate: true })
|
||||||
|
|
||||||
|
defineExpose({ refresh: () => loadTrace(true) })
|
||||||
|
|
||||||
// 格式化时间(详细)
|
// 格式化时间(详细)
|
||||||
const formatTime = (dateStr: string) => {
|
const formatTime = (dateStr: string) => {
|
||||||
const date = new Date(dateStr)
|
const date = new Date(dateStr)
|
||||||
|
|||||||
@@ -66,13 +66,13 @@
|
|||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
class="h-8 w-8"
|
class="h-8 w-8"
|
||||||
:disabled="loading"
|
:disabled="loading && !autoRefreshing"
|
||||||
title="刷新"
|
:title="autoRefreshing ? '停止自动刷新' : '刷新'"
|
||||||
@click="refreshDetail"
|
@click="refreshDetail"
|
||||||
>
|
>
|
||||||
<RefreshCw
|
<RefreshCw
|
||||||
class="w-4 h-4"
|
class="w-4 h-4"
|
||||||
:class="{ 'animate-spin': loading }"
|
:class="{ 'animate-spin': loading || autoRefreshing, 'text-primary': autoRefreshing }"
|
||||||
/>
|
/>
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
@@ -275,25 +275,27 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- ========== 3. 按次计费(独立隔离) ========== -->
|
<!-- ========== 3. 按次计费 ========== -->
|
||||||
<div
|
<div
|
||||||
v-if="perRequestCost > 0 && !detail.video_billing"
|
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">
|
<div class="flex items-center justify-between text-xs">
|
||||||
<span class="font-medium text-amber-600 dark:text-amber-400">按次计费</span>
|
<span class="font-medium text-foreground">按次计费</span>
|
||||||
<span
|
|
||||||
v-if="detail.price_per_request"
|
|
||||||
class="text-muted-foreground"
|
|
||||||
>
|
|
||||||
单价 ${{ detail.price_per_request.toFixed(6) }}/次
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center">
|
<div class="rounded-lg p-3 bg-primary/5 border border-primary/30 space-y-2">
|
||||||
<div class="flex items-center flex-1">
|
<div
|
||||||
<span class="text-xs text-muted-foreground w-[56px]">请求次数</span>
|
v-if="detail.price_per_request"
|
||||||
<span class="text-sm font-semibold font-mono flex-1 text-center">1</span>
|
class="flex items-center justify-end text-xs"
|
||||||
<span class="text-xs font-mono font-medium">${{ perRequestCost.toFixed(6) }}</span>
|
>
|
||||||
|
<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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -361,6 +363,7 @@
|
|||||||
<!-- 请求链路追踪卡片 -->
|
<!-- 请求链路追踪卡片 -->
|
||||||
<div v-if="detail.request_id || detail.id">
|
<div v-if="detail.request_id || detail.id">
|
||||||
<HorizontalRequestTimeline
|
<HorizontalRequestTimeline
|
||||||
|
ref="timelineRef"
|
||||||
:request-id="detail.request_id || detail.id"
|
:request-id="detail.request_id || detail.id"
|
||||||
:override-status-code="detail.status_code"
|
:override-status-code="detail.status_code"
|
||||||
/>
|
/>
|
||||||
@@ -590,7 +593,7 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<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 Button from '@/components/ui/button.vue'
|
||||||
import { useEscapeKey } from '@/composables/useEscapeKey'
|
import { useEscapeKey } from '@/composables/useEscapeKey'
|
||||||
import { useClipboard } from '@/composables/useClipboard'
|
import { useClipboard } from '@/composables/useClipboard'
|
||||||
@@ -631,6 +634,7 @@ const emit = defineEmits<{
|
|||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
const error = ref<string | null>(null)
|
const error = ref<string | null>(null)
|
||||||
const detail = ref<RequestDetail | null>(null)
|
const detail = ref<RequestDetail | null>(null)
|
||||||
|
const timelineRef = ref<InstanceType<typeof HorizontalRequestTimeline> | null>(null)
|
||||||
const activeTab = ref('request-body')
|
const activeTab = ref('request-body')
|
||||||
const copiedStates = ref<Record<string, boolean>>({})
|
const copiedStates = ref<Record<string, boolean>>({})
|
||||||
const viewMode = ref<'compare' | 'formatted' | 'raw'>('formatted')
|
const viewMode = ref<'compare' | 'formatted' | 'raw'>('formatted')
|
||||||
@@ -645,6 +649,8 @@ const historicalPricing = ref<{
|
|||||||
cache_read_price: string
|
cache_read_price: string
|
||||||
request_price: string
|
request_price: string
|
||||||
} | null>(null)
|
} | null>(null)
|
||||||
|
const autoRefreshTimer = ref<ReturnType<typeof setInterval> | null>(null)
|
||||||
|
const autoRefreshing = ref(false)
|
||||||
|
|
||||||
// 监听标签页切换
|
// 监听标签页切换
|
||||||
watch(activeTab, (newTab) => {
|
watch(activeTab, (newTab) => {
|
||||||
@@ -870,26 +876,38 @@ watch(() => props.requestId, async (newId) => {
|
|||||||
watch(() => props.isOpen, async (isOpen) => {
|
watch(() => props.isOpen, async (isOpen) => {
|
||||||
if (isOpen && props.requestId) {
|
if (isOpen && props.requestId) {
|
||||||
await loadDetail(props.requestId)
|
await loadDetail(props.requestId)
|
||||||
|
} else if (!isOpen) {
|
||||||
|
stopAutoRefresh()
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
async function loadDetail(id: string) {
|
async function loadDetail(id: string, silent = false) {
|
||||||
loading.value = true
|
if (!silent) {
|
||||||
|
loading.value = true
|
||||||
|
historicalPricing.value = null
|
||||||
|
}
|
||||||
error.value = null
|
error.value = null
|
||||||
historicalPricing.value = null
|
|
||||||
try {
|
try {
|
||||||
detail.value = await dashboardApi.getRequestDetail(id)
|
detail.value = await dashboardApi.getRequestDetail(id)
|
||||||
|
|
||||||
// 默认显示有内容的第一个可见 tab
|
// 首次加载时选择默认 tab
|
||||||
const visibleTabNames = visibleTabs.value.map(t => t.name)
|
if (!silent) {
|
||||||
if (detail.value.request_body && visibleTabNames.includes('request-body')) {
|
const visibleTabNames = visibleTabs.value.map(t => t.name)
|
||||||
activeTab.value = 'request-body'
|
if (detail.value.request_body && visibleTabNames.includes('request-body')) {
|
||||||
} else if (detail.value.response_body && visibleTabNames.includes('response-body')) {
|
activeTab.value = 'request-body'
|
||||||
activeTab.value = 'response-body'
|
} else if (detail.value.response_body && visibleTabNames.includes('response-body')) {
|
||||||
} else if (visibleTabNames.length > 0) {
|
activeTab.value = 'response-body'
|
||||||
activeTab.value = visibleTabNames[0]
|
} 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) {
|
if (detail.value.input_price_per_1m || detail.value.output_price_per_1m || detail.value.price_per_request) {
|
||||||
historicalPricing.value = {
|
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'
|
request_price: detail.value.price_per_request ? detail.value.price_per_request.toFixed(4) : 'N/A'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 静默刷新时同步刷新链路追踪
|
||||||
|
if (silent) {
|
||||||
|
timelineRef.value?.refresh()
|
||||||
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
log.error('Failed to load request detail:', err)
|
log.error('Failed to load request detail:', err)
|
||||||
error.value = '加载请求详情失败'
|
if (!silent) {
|
||||||
|
error.value = '加载请求详情失败'
|
||||||
|
}
|
||||||
} finally {
|
} finally {
|
||||||
loading.value = false
|
if (!silent) {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleClose() {
|
function handleClose() {
|
||||||
|
stopAutoRefresh()
|
||||||
emit('close')
|
emit('close')
|
||||||
}
|
}
|
||||||
|
|
||||||
async function refreshDetail() {
|
function isRequestCompleted(): boolean {
|
||||||
if (props.requestId) {
|
if (!detail.value?.status) return true
|
||||||
await loadDetail(props.requestId)
|
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 {
|
function formatDateTime(dateStr: string | null | undefined): string {
|
||||||
if (!dateStr) return 'N/A'
|
if (!dateStr) return 'N/A'
|
||||||
const date = new Date(dateStr)
|
const date = new Date(dateStr)
|
||||||
|
|||||||
@@ -193,7 +193,7 @@
|
|||||||
<div class="flex flex-col items-end flex-shrink-0">
|
<div class="flex flex-col items-end flex-shrink-0">
|
||||||
<span class="text-xs text-primary font-medium">{{ formatCurrency(record.cost || 0) }}</span>
|
<span class="text-xs text-primary font-medium">{{ formatCurrency(record.cost || 0) }}</span>
|
||||||
<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"
|
class="text-[10px] text-muted-foreground"
|
||||||
>{{ formatCurrency(record.actual_cost) }}</span>
|
>{{ formatCurrency(record.actual_cost) }}</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -562,7 +562,7 @@
|
|||||||
<div class="flex flex-col items-end text-xs gap-0.5">
|
<div class="flex flex-col items-end text-xs gap-0.5">
|
||||||
<span class="text-primary font-medium">{{ formatCurrency(record.cost || 0) }}</span>
|
<span class="text-primary font-medium">{{ formatCurrency(record.cost || 0) }}</span>
|
||||||
<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"
|
class="text-muted-foreground"
|
||||||
>
|
>
|
||||||
{{ formatCurrency(record.actual_cost) }}
|
{{ formatCurrency(record.actual_cost) }}
|
||||||
|
|||||||
@@ -1009,7 +1009,7 @@ class AdminRefreshProviderQuotaAdapter(AdminApiAdapter):
|
|||||||
|
|
||||||
# 获取端点:
|
# 获取端点:
|
||||||
# - Codex: openai:cli
|
# - Codex: openai:cli
|
||||||
# - Antigravity: gemini:cli(用于触发 oauth 刷新 + 提供 auth_config.project_id)
|
# - Antigravity: gemini:chat(用于触发 oauth 刷新 + 提供 auth_config.project_id)
|
||||||
endpoint = None
|
endpoint = None
|
||||||
if provider_type == ProviderType.CODEX:
|
if provider_type == ProviderType.CODEX:
|
||||||
for ep in provider.endpoints:
|
for ep in provider.endpoints:
|
||||||
@@ -1019,12 +1019,16 @@ class AdminRefreshProviderQuotaAdapter(AdminApiAdapter):
|
|||||||
if not endpoint:
|
if not endpoint:
|
||||||
raise InvalidRequestException("找不到有效的 openai:cli 端点")
|
raise InvalidRequestException("找不到有效的 openai:cli 端点")
|
||||||
else:
|
else:
|
||||||
for ep in provider.endpoints:
|
# Prefer the new signature, but keep backward-compat with existing DB rows.
|
||||||
if ep.api_format == "gemini:cli" and ep.is_active:
|
for sig in ("gemini:chat", "gemini:cli"):
|
||||||
endpoint = ep
|
for ep in provider.endpoints:
|
||||||
|
if ep.api_format == sig and ep.is_active:
|
||||||
|
endpoint = ep
|
||||||
|
break
|
||||||
|
if endpoint is not None:
|
||||||
break
|
break
|
||||||
if not endpoint:
|
if not endpoint:
|
||||||
raise InvalidRequestException("找不到有效的 gemini:cli 端点")
|
raise InvalidRequestException("找不到有效的 gemini:chat/gemini:cli 端点")
|
||||||
|
|
||||||
results: list[dict] = []
|
results: list[dict] = []
|
||||||
success_count = 0
|
success_count = 0
|
||||||
|
|||||||
@@ -70,7 +70,7 @@ async def _resolve_key_auth(
|
|||||||
auth_config: dict[str, Any] | None = None
|
auth_config: dict[str, Any] | None = None
|
||||||
|
|
||||||
if auth_type == "oauth":
|
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:
|
try:
|
||||||
resolved = await resolve_oauth_access_token(
|
resolved = await resolve_oauth_access_token(
|
||||||
key_id=str(api_key.id),
|
key_id=str(api_key.id),
|
||||||
|
|||||||
@@ -1181,6 +1181,7 @@ class AdminUsageDetailAdapter(AdminApiAdapter):
|
|||||||
"is_stream": usage_record.is_stream,
|
"is_stream": usage_record.is_stream,
|
||||||
"status_code": usage_record.status_code,
|
"status_code": usage_record.status_code,
|
||||||
"error_message": usage_record.error_message,
|
"error_message": usage_record.error_message,
|
||||||
|
"status": usage_record.status,
|
||||||
"response_time_ms": usage_record.response_time_ms,
|
"response_time_ms": usage_record.response_time_ms,
|
||||||
"first_byte_time_ms": usage_record.first_byte_time_ms, # 首字时间 (TTFB)
|
"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,
|
"created_at": usage_record.created_at.isoformat() if usage_record.created_at else None,
|
||||||
|
|||||||
@@ -571,7 +571,7 @@ class BaseMessageHandler:
|
|||||||
api_format = ctx.api_format
|
api_format = ctx.api_format
|
||||||
# 格式转换追踪
|
# 格式转换追踪
|
||||||
endpoint_api_format = ctx.provider_api_format or None
|
endpoint_api_format = ctx.provider_api_format or None
|
||||||
has_format_conversion = ctx.needs_conversion
|
has_format_conversion = ctx.has_format_conversion
|
||||||
|
|
||||||
# 如果 provider 为空,记录警告(不应该发生,但用于调试)
|
# 如果 provider 为空,记录警告(不应该发生,但用于调试)
|
||||||
if not provider:
|
if not provider:
|
||||||
|
|||||||
@@ -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.parsers import get_parser_for_format
|
||||||
from src.api.handlers.base.request_builder import PassthroughRequestBuilder, get_provider_auth
|
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.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_processor import StreamProcessor
|
||||||
from src.api.handlers.base.stream_telemetry import StreamTelemetryRecorder
|
from src.api.handlers.base.stream_telemetry import StreamTelemetryRecorder
|
||||||
from src.api.handlers.base.upstream_stream_bridge import (
|
from src.api.handlers.base.upstream_stream_bridge import (
|
||||||
@@ -1322,7 +1322,7 @@ class ChatHandlerBase(BaseMessageHandler, ABC):
|
|||||||
client_response_headers=client_response_headers,
|
client_response_headers=client_response_headers,
|
||||||
# 格式转换追踪
|
# 格式转换追踪
|
||||||
endpoint_api_format=ctx.provider_api_format or None,
|
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,
|
target_model=ctx.mapped_model,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -1367,7 +1367,7 @@ class ChatHandlerBase(BaseMessageHandler, ABC):
|
|||||||
provider_request_body: dict[str, Any] | None = None
|
provider_request_body: dict[str, Any] | None = None
|
||||||
provider_api_format_for_error: str | None = None
|
provider_api_format_for_error: str | None = None
|
||||||
client_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(用于失败记录)
|
provider_id: str | None = None # Provider ID(用于失败记录)
|
||||||
endpoint_id: str | None = None # Endpoint ID(用于失败记录)
|
endpoint_id: str | None = None # Endpoint ID(用于失败记录)
|
||||||
key_id: str | None = None # Key ID(用于失败记录)
|
key_id: str | None = None # Key ID(用于失败记录)
|
||||||
@@ -1795,7 +1795,9 @@ class ChatHandlerBase(BaseMessageHandler, ABC):
|
|||||||
api_format=api_format,
|
api_format=api_format,
|
||||||
# 格式转换追踪
|
# 格式转换追踪
|
||||||
endpoint_api_format=provider_api_format_for_error or None,
|
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_id=provider_id,
|
||||||
provider_endpoint_id=endpoint_id,
|
provider_endpoint_id=endpoint_id,
|
||||||
provider_api_key_id=key_id,
|
provider_api_key_id=key_id,
|
||||||
@@ -1865,7 +1867,9 @@ class ChatHandlerBase(BaseMessageHandler, ABC):
|
|||||||
client_response_headers={"content-type": "application/json"},
|
client_response_headers={"content-type": "application/json"},
|
||||||
# 格式转换追踪
|
# 格式转换追踪
|
||||||
endpoint_api_format=provider_api_format_for_error or None,
|
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,
|
target_model=mapped_model_result,
|
||||||
request_metadata=request_metadata,
|
request_metadata=request_metadata,
|
||||||
)
|
)
|
||||||
@@ -1916,7 +1920,9 @@ class ChatHandlerBase(BaseMessageHandler, ABC):
|
|||||||
client_response_headers={"content-type": "application/json"},
|
client_response_headers={"content-type": "application/json"},
|
||||||
# 格式转换追踪
|
# 格式转换追踪
|
||||||
endpoint_api_format=provider_api_format_for_error or None,
|
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,
|
target_model=mapped_model_result,
|
||||||
request_metadata=request_metadata,
|
request_metadata=request_metadata,
|
||||||
|
|||||||
@@ -43,7 +43,7 @@ from src.api.handlers.base.request_builder import PassthroughRequestBuilder, get
|
|||||||
from src.api.handlers.base.response_parser import (
|
from src.api.handlers.base.response_parser import (
|
||||||
ResponseParser,
|
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 (
|
from src.api.handlers.base.upstream_stream_bridge import (
|
||||||
aggregate_upstream_stream_to_internal_response,
|
aggregate_upstream_stream_to_internal_response,
|
||||||
)
|
)
|
||||||
@@ -2459,7 +2459,7 @@ class CliMessageHandlerBase(BaseMessageHandler):
|
|||||||
response_headers=ctx.response_headers,
|
response_headers=ctx.response_headers,
|
||||||
client_response_headers=client_response_headers,
|
client_response_headers=client_response_headers,
|
||||||
endpoint_api_format=ctx.provider_api_format or None,
|
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,
|
target_model=ctx.mapped_model,
|
||||||
request_metadata=request_metadata,
|
request_metadata=request_metadata,
|
||||||
)
|
)
|
||||||
@@ -2492,7 +2492,7 @@ class CliMessageHandlerBase(BaseMessageHandler):
|
|||||||
client_response_headers=client_response_headers,
|
client_response_headers=client_response_headers,
|
||||||
# 格式转换追踪
|
# 格式转换追踪
|
||||||
endpoint_api_format=ctx.provider_api_format or None,
|
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,
|
target_model=ctx.mapped_model,
|
||||||
request_metadata=request_metadata,
|
request_metadata=request_metadata,
|
||||||
@@ -2552,7 +2552,7 @@ class CliMessageHandlerBase(BaseMessageHandler):
|
|||||||
api_format=ctx.api_format,
|
api_format=ctx.api_format,
|
||||||
# 格式转换追踪
|
# 格式转换追踪
|
||||||
endpoint_api_format=ctx.provider_api_format or None,
|
endpoint_api_format=ctx.provider_api_format or None,
|
||||||
has_format_conversion=ctx.needs_conversion,
|
has_format_conversion=ctx.has_format_conversion,
|
||||||
# Provider 侧追踪信息(用于记录真实成本)
|
# Provider 侧追踪信息(用于记录真实成本)
|
||||||
provider_id=ctx.provider_id,
|
provider_id=ctx.provider_id,
|
||||||
provider_endpoint_id=ctx.endpoint_id,
|
provider_endpoint_id=ctx.endpoint_id,
|
||||||
@@ -2709,7 +2709,7 @@ class CliMessageHandlerBase(BaseMessageHandler):
|
|||||||
client_response_headers=client_response_headers,
|
client_response_headers=client_response_headers,
|
||||||
# 格式转换追踪
|
# 格式转换追踪
|
||||||
endpoint_api_format=ctx.provider_api_format or None,
|
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,
|
target_model=ctx.mapped_model,
|
||||||
request_metadata=request_metadata,
|
request_metadata=request_metadata,
|
||||||
@@ -3170,7 +3170,7 @@ class CliMessageHandlerBase(BaseMessageHandler):
|
|||||||
api_format=api_format,
|
api_format=api_format,
|
||||||
# 格式转换追踪
|
# 格式转换追踪
|
||||||
endpoint_api_format=provider_api_format or None,
|
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 侧追踪信息(用于记录真实成本)
|
||||||
provider_id=provider_id,
|
provider_id=provider_id,
|
||||||
provider_endpoint_id=endpoint_id,
|
provider_endpoint_id=endpoint_id,
|
||||||
@@ -3249,7 +3249,7 @@ class CliMessageHandlerBase(BaseMessageHandler):
|
|||||||
client_response_headers={"content-type": "application/json"},
|
client_response_headers={"content-type": "application/json"},
|
||||||
# 格式转换追踪
|
# 格式转换追踪
|
||||||
endpoint_api_format=provider_api_format or None,
|
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,
|
target_model=mapped_model_result,
|
||||||
request_metadata=request_metadata,
|
request_metadata=request_metadata,
|
||||||
@@ -3364,7 +3364,7 @@ class CliMessageHandlerBase(BaseMessageHandler):
|
|||||||
first_byte_time_ms=ctx.first_byte_time_ms,
|
first_byte_time_ms=ctx.first_byte_time_ms,
|
||||||
api_format=ctx.api_format,
|
api_format=ctx.api_format,
|
||||||
endpoint_api_format=ctx.provider_api_format or None,
|
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:
|
except Exception as e:
|
||||||
logger.warning(f"[{self.request_id}] 同步更新 streaming 状态失败: {e}")
|
logger.warning(f"[{self.request_id}] 同步更新 streaming 状态失败: {e}")
|
||||||
|
|||||||
@@ -18,6 +18,18 @@ if TYPE_CHECKING:
|
|||||||
from src.core.api_format.conversion.stream_state import StreamState
|
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
|
@dataclass
|
||||||
class StreamContext:
|
class StreamContext:
|
||||||
"""
|
"""
|
||||||
@@ -234,6 +246,15 @@ class StreamContext:
|
|||||||
if self.first_byte_time_ms is None:
|
if self.first_byte_time_ms is None:
|
||||||
self.first_byte_time_ms = int((time.time() - start_time) * 1000)
|
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:
|
def is_success(self) -> bool:
|
||||||
"""检查请求是否成功"""
|
"""检查请求是否成功"""
|
||||||
return self.status_code < 400
|
return self.status_code < 400
|
||||||
|
|||||||
@@ -108,6 +108,115 @@ class StreamProcessor:
|
|||||||
pass
|
pass
|
||||||
return self.default_parser
|
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(
|
def handle_sse_event(
|
||||||
self,
|
self,
|
||||||
ctx: StreamContext,
|
ctx: StreamContext,
|
||||||
@@ -115,6 +224,7 @@ class StreamProcessor:
|
|||||||
data_str: str,
|
data_str: str,
|
||||||
*,
|
*,
|
||||||
skip_record: bool = False,
|
skip_record: bool = False,
|
||||||
|
skip_ctx_update: bool = False,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""
|
"""
|
||||||
处理单个 SSE 事件
|
处理单个 SSE 事件
|
||||||
@@ -126,6 +236,7 @@ class StreamProcessor:
|
|||||||
event_name: 事件名称
|
event_name: 事件名称
|
||||||
data_str: 事件数据字符串
|
data_str: 事件数据字符串
|
||||||
skip_record: 是否跳过记录到 parsed_chunks(当需要格式转换时应为 True)
|
skip_record: 是否跳过记录到 parsed_chunks(当需要格式转换时应为 True)
|
||||||
|
skip_ctx_update: 跳过 usage/completion/text 提取(由调用方统一处理时使用)
|
||||||
"""
|
"""
|
||||||
if not data_str:
|
if not data_str:
|
||||||
return
|
return
|
||||||
@@ -142,31 +253,17 @@ class StreamProcessor:
|
|||||||
if not isinstance(data, dict):
|
if not isinstance(data, dict):
|
||||||
return
|
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 统计/记录转换后的数据)
|
# 统计数据事件数量(当需要格式转换时跳过,由 _emit_converted_line 统计/记录转换后的数据)
|
||||||
if not skip_record:
|
if not skip_record:
|
||||||
ctx.data_count += 1
|
ctx.data_count += 1
|
||||||
if ctx.record_parsed_chunks:
|
if ctx.record_parsed_chunks:
|
||||||
ctx.parsed_chunks.append(data)
|
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", "")
|
event_type = event_name or data.get("type", "")
|
||||||
if event_type in ("response.completed", "message_stop"):
|
if event_type in ("response.completed", "message_stop"):
|
||||||
@@ -476,14 +573,31 @@ class StreamProcessor:
|
|||||||
self.on_streaming_start()
|
self.on_streaming_start()
|
||||||
streaming_started = True
|
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
|
nonlocal parse_time
|
||||||
if perf_capture:
|
if perf_capture:
|
||||||
t0 = time.perf_counter()
|
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
|
parse_time += time.perf_counter() - t0
|
||||||
return
|
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:
|
def _build_stream_error_payload(message: str) -> dict:
|
||||||
if client_family == "openai":
|
if client_family == "openai":
|
||||||
@@ -589,6 +703,14 @@ class StreamProcessor:
|
|||||||
data=data_obj,
|
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:
|
try:
|
||||||
converted_events = registry.convert_stream_chunk(
|
converted_events = registry.convert_stream_chunk(
|
||||||
data_obj,
|
data_obj,
|
||||||
@@ -648,7 +770,9 @@ class StreamProcessor:
|
|||||||
|
|
||||||
if line:
|
if line:
|
||||||
# 需要格式转换时,跳过记录原始数据(由 _emit_converted_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 ""
|
normalized_line = line.rstrip("\r\n") if line else ""
|
||||||
out_chunks = _emit_converted_line(normalized_line)
|
out_chunks = _emit_converted_line(normalized_line)
|
||||||
if not out_chunks:
|
if not out_chunks:
|
||||||
@@ -878,6 +1002,7 @@ class StreamProcessor:
|
|||||||
line: str,
|
line: str,
|
||||||
*,
|
*,
|
||||||
skip_record: bool = False,
|
skip_record: bool = False,
|
||||||
|
skip_ctx_update: bool = False,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""
|
"""
|
||||||
处理单行数据
|
处理单行数据
|
||||||
@@ -887,6 +1012,7 @@ class StreamProcessor:
|
|||||||
sse_parser: SSE 解析器
|
sse_parser: SSE 解析器
|
||||||
line: 原始行数据
|
line: 原始行数据
|
||||||
skip_record: 是否跳过记录到 parsed_chunks(当需要格式转换时应为 True)
|
skip_record: 是否跳过记录到 parsed_chunks(当需要格式转换时应为 True)
|
||||||
|
skip_ctx_update: 跳过 usage/completion/text 提取(由调用方统一处理时使用)
|
||||||
"""
|
"""
|
||||||
# SSEEventParser 以"去掉换行符"的单行文本作为输入;这里统一剔除 CR/LF,
|
# SSEEventParser 以"去掉换行符"的单行文本作为输入;这里统一剔除 CR/LF,
|
||||||
# 避免把空行误判成 "\n" 并导致事件边界解析错误。
|
# 避免把空行误判成 "\n" 并导致事件边界解析错误。
|
||||||
@@ -898,7 +1024,11 @@ class StreamProcessor:
|
|||||||
|
|
||||||
for event in events:
|
for event in events:
|
||||||
self.handle_sse_event(
|
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(
|
async def create_monitored_stream(
|
||||||
|
|||||||
@@ -227,7 +227,7 @@ class StreamTelemetryRecorder:
|
|||||||
request_type="chat",
|
request_type="chat",
|
||||||
metadata=metadata,
|
metadata=metadata,
|
||||||
endpoint_api_format=ctx.provider_api_format,
|
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} 流式响应完成")
|
logger.debug(f"{self.format_id} 流式响应完成")
|
||||||
@@ -273,7 +273,7 @@ class StreamTelemetryRecorder:
|
|||||||
request_type="chat",
|
request_type="chat",
|
||||||
metadata=metadata,
|
metadata=metadata,
|
||||||
endpoint_api_format=ctx.provider_api_format,
|
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} 流式响应中断")
|
logger.debug(f"{self.format_id} 流式响应中断")
|
||||||
@@ -320,7 +320,7 @@ class StreamTelemetryRecorder:
|
|||||||
request_type="chat",
|
request_type="chat",
|
||||||
metadata=metadata,
|
metadata=metadata,
|
||||||
endpoint_api_format=ctx.provider_api_format,
|
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} 流式响应被客户端取消")
|
logger.debug(f"{self.format_id} 流式响应被客户端取消")
|
||||||
|
|||||||
@@ -106,7 +106,7 @@ FIXED_PROVIDERS: dict[ProviderType, FixedProviderTemplate] = {
|
|||||||
provider_type=ProviderType.ANTIGRAVITY,
|
provider_type=ProviderType.ANTIGRAVITY,
|
||||||
display_name="Antigravity",
|
display_name="Antigravity",
|
||||||
api_base_url=ANTIGRAVITY_PROD_URL,
|
api_base_url=ANTIGRAVITY_PROD_URL,
|
||||||
endpoint_signatures=["gemini:cli"],
|
endpoint_signatures=["gemini:chat"],
|
||||||
oauth=FixedProviderOAuth(
|
oauth=FixedProviderOAuth(
|
||||||
authorize_url="https://accounts.google.com/o/oauth2/v2/auth",
|
authorize_url="https://accounts.google.com/o/oauth2/v2/auth",
|
||||||
token_url="https://oauth2.googleapis.com/token",
|
token_url="https://oauth2.googleapis.com/token",
|
||||||
|
|||||||
@@ -303,7 +303,9 @@ class ModelFetchScheduler:
|
|||||||
# Use request_builder's lazy refresh logic and persist refreshed token back to DB.
|
# 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 signature is only used for tracing/debug; auth logic doesn't depend on it.
|
||||||
endpoint_api_format = (
|
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:
|
try:
|
||||||
resolved = await resolve_oauth_access_token(
|
resolved = await resolve_oauth_access_token(
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
"""Antigravity v1internal request/response envelope helpers.
|
"""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:
|
wire format:
|
||||||
- Request: V1InternalRequest (top-level metadata + nested GeminiRequest)
|
- Request: V1InternalRequest (top-level metadata + nested GeminiRequest)
|
||||||
- Response: V1InternalResponse (top-level responseId + nested GeminiResponse)
|
- Response: V1InternalResponse (top-level responseId + nested GeminiResponse)
|
||||||
|
|||||||
@@ -249,7 +249,7 @@ async def fetch_models_antigravity(
|
|||||||
"id": model_id,
|
"id": model_id,
|
||||||
"owned_by": "antigravity",
|
"owned_by": "antigravity",
|
||||||
"display_name": display_name,
|
"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
|
from src.services.provider.transport import register_transport_hook
|
||||||
|
|
||||||
# Envelope
|
# 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", "gemini:cli", antigravity_v1internal_envelope)
|
||||||
register_envelope("antigravity", "", antigravity_v1internal_envelope)
|
register_envelope("antigravity", "", antigravity_v1internal_envelope)
|
||||||
|
|
||||||
# Transport
|
# 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)
|
register_transport_hook("antigravity", "gemini:cli", build_antigravity_url)
|
||||||
|
|
||||||
# Auth
|
# Auth
|
||||||
|
|||||||
@@ -106,7 +106,7 @@ def cache_result(
|
|||||||
if cached:
|
if cached:
|
||||||
try:
|
try:
|
||||||
result = json.loads(cached)
|
result = json.loads(cached)
|
||||||
logger.debug(f"缓存命中: {cache_key}")
|
logger.trace(f"缓存命中: {cache_key}")
|
||||||
return result
|
return result
|
||||||
except json.JSONDecodeError as e:
|
except json.JSONDecodeError as e:
|
||||||
logger.warning(f"缓存解析失败,删除损坏缓存: {cache_key}, 错误: {e}")
|
logger.warning(f"缓存解析失败,删除损坏缓存: {cache_key}, 错误: {e}")
|
||||||
@@ -123,7 +123,7 @@ def cache_result(
|
|||||||
await redis_client.setex(
|
await redis_client.setex(
|
||||||
cache_key, ttl, json.dumps(result, ensure_ascii=False, default=str)
|
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:
|
except Exception as e:
|
||||||
logger.warning(f"保存缓存失败: {e}")
|
logger.warning(f"保存缓存失败: {e}")
|
||||||
|
|
||||||
|
|||||||
@@ -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 = StreamContext(model="gemini-2.0-flash", api_format="gemini:cli")
|
||||||
ctx.provider_type = "antigravity"
|
ctx.provider_type = "antigravity"
|
||||||
ctx.provider_api_format = "gemini:cli"
|
ctx.provider_api_format = "gemini:chat"
|
||||||
ctx.client_api_format = "gemini:cli"
|
ctx.client_api_format = "gemini:cli"
|
||||||
|
|
||||||
v1_line = (
|
v1_line = (
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ def test_antigravity_converts_claude_thinking_block_to_gemini_thought_part_prefe
|
|||||||
}
|
}
|
||||||
|
|
||||||
registry = get_format_converter_registry()
|
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)
|
assert isinstance(out.get("contents"), list)
|
||||||
model_turn = out["contents"][1]
|
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()
|
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"]
|
parts = out["contents"][1]["parts"]
|
||||||
assert parts[0]["thought"] is True
|
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()
|
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"]
|
parts = out["contents"][1]["parts"]
|
||||||
assert all(p.get("thought") is not True for p in 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()
|
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"]
|
parts = out["contents"][1]["parts"]
|
||||||
assert parts[0]["thought"] is True
|
assert parts[0]["thought"] is True
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ class _DummyEndpoint:
|
|||||||
def test_antigravity_uses_v1internal_path_and_sets_contextvar() -> None:
|
def test_antigravity_uses_v1internal_path_and_sets_contextvar() -> None:
|
||||||
endpoint = _DummyEndpoint(
|
endpoint = _DummyEndpoint(
|
||||||
base_url="https://ignored.example.com",
|
base_url="https://ignored.example.com",
|
||||||
api_format="gemini:cli",
|
api_format="gemini:chat",
|
||||||
provider=SimpleNamespace(provider_type="antigravity"),
|
provider=SimpleNamespace(provider_type="antigravity"),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ async def test_get_provider_auth_oauth_returns_decrypted_auth_config() -> None:
|
|||||||
"project_id": "project-1",
|
"project_id": "project-1",
|
||||||
}
|
}
|
||||||
|
|
||||||
endpoint = SimpleNamespace(api_format="gemini:cli")
|
endpoint = SimpleNamespace(api_format="gemini:chat")
|
||||||
key = SimpleNamespace(
|
key = SimpleNamespace(
|
||||||
id="k1",
|
id="k1",
|
||||||
auth_type="oauth",
|
auth_type="oauth",
|
||||||
|
|||||||
Reference in New Issue
Block a user