Merge upstream/main into feat/grok-complete-integration

冲突集中在 chat completions / messages 两条 Responses 转发路径:
upstream 给 OpenAIForwardResult 增加了 UpstreamResponseModel 与
UpstreamResponseModelConflict(配套 beginUpstreamResponseModelObservation
观测器),本分支在同样位置把返回值改成了具名变量以便挂 Grok 原生搜索计数。
两侧不互斥,合并结果同时保留上游的响应模型观测字段与 Grok SearchCount 逻辑。

frontend/pnpm-lock.yaml 取 upstream 版本:package.json 与 upstream 完全一致,
本地差异只是 pnpm install 的重解析噪音。
This commit is contained in:
IanShaw027
2026-08-08 11:12:56 +08:00
141 changed files with 5942 additions and 680 deletions
+3
View File
@@ -56,6 +56,7 @@ export interface TrendParams {
request_type?: UsageRequestType
stream?: boolean
billing_type?: number | null
upstream_model_mismatch?: boolean
}
export interface TrendResponse {
@@ -87,6 +88,7 @@ export interface ModelStatsParams {
request_type?: UsageRequestType
stream?: boolean
billing_type?: number | null
upstream_model_mismatch?: boolean
}
export interface ModelStatsResponse {
@@ -115,6 +117,7 @@ export interface GroupStatsParams {
request_type?: UsageRequestType
stream?: boolean
billing_type?: number | null
upstream_model_mismatch?: boolean
}
export interface GroupStatsResponse {
+2
View File
@@ -84,6 +84,7 @@ export interface AdminUsageQueryParams extends UsageQueryParams {
user_id?: number
exact_total?: boolean
billing_mode?: string
upstream_model_mismatch?: boolean
sort_by?: string
sort_order?: 'asc' | 'desc'
// 错误请求 tab 专属筛选(仅传给错误列表接口;共用同一 filters 对象)
@@ -123,6 +124,7 @@ export async function getStats(params: {
model?: string
request_type?: UsageRequestType
stream?: boolean
upstream_model_mismatch?: boolean
period?: string
start_date?: string
end_date?: string
@@ -139,6 +139,11 @@
<Select v-model="filters.billing_mode" :options="billingModeOptions" @change="emitChange" />
</div>
<div v-if="mode === 'usage'" class="w-full sm:w-auto sm:min-w-[220px]">
<label class="input-label">{{ t('admin.usage.upstreamModelAudit') }}</label>
<Select v-model="filters.upstream_model_mismatch" :options="upstreamModelMismatchOptions" @change="emitChange" />
</div>
<!-- Error Phase Filter (errors only) -->
<div v-if="mode === 'errors'" class="w-full sm:w-auto sm:min-w-[180px]">
<label class="input-label">{{ t('admin.ops.errorLog.type') }}</label>
@@ -307,6 +312,12 @@ const billingModeOptions = ref<SelectOption[]>([
{ value: 'video', label: t('admin.usage.billingModeVideo') }
])
const upstreamModelMismatchOptions = ref<SelectOption[]>([
{ value: null, label: t('admin.usage.allUpstreamModelAudit') },
{ value: true, label: t('admin.usage.upstreamModelMismatchOnly') },
{ value: false, label: t('admin.usage.upstreamModelMatchedOnly') }
])
const emitChange = () => emit('change')
const clearPendingUserSearch = () => {
@@ -53,23 +53,41 @@
</template>
<template #cell-model="{ row }">
<div v-if="row.model_mapping_chain && row.model_mapping_chain.includes('→')" class="space-y-0.5 text-xs">
<div v-for="(step, i) in row.model_mapping_chain.split('→')" :key="i"
class="break-all"
:class="i === 0 ? 'font-medium text-gray-900 dark:text-white' : 'text-gray-500 dark:text-gray-400'"
:style="i > 0 ? `padding-left: ${i * 0.75}rem` : ''">
<span v-if="i > 0" class="mr-0.5">↳</span>{{ step }}
<div class="space-y-0.5 text-xs">
<div v-if="row.model_mapping_chain && row.model_mapping_chain.includes('→')" class="space-y-0.5">
<div v-for="(step, i) in row.model_mapping_chain.split('→')" :key="i"
class="break-all"
:class="i === 0 ? 'font-medium text-gray-900 dark:text-white' : 'text-gray-500 dark:text-gray-400'"
:style="i > 0 ? `padding-left: ${i * 0.75}rem` : ''">
<span v-if="i > 0" class="mr-0.5">↳</span>{{ step }}
</div>
</div>
<div v-else-if="row.upstream_model && row.upstream_model !== row.model" class="space-y-0.5">
<div class="break-all font-medium text-gray-900 dark:text-white">
{{ row.model }}
</div>
<div class="break-all text-gray-500 dark:text-gray-400">
<span class="mr-0.5">↳</span>{{ row.upstream_model }}
</div>
</div>
<span v-else class="font-medium text-gray-900 dark:text-white">{{ row.model }}</span>
<div
v-if="row.upstream_model_mismatch === true && row.upstream_response_model"
class="break-all pl-3 text-[11px]"
:class="isLikelyModelVariant(row) ? 'text-amber-600 dark:text-amber-400' : 'text-orange-600 dark:text-orange-400'"
:title="modelAuditTitle(row)"
>
<span class="mr-1">↳ {{ t('usage.upstreamResponseModel') }}:</span>{{ row.upstream_response_model }}
<span
class="ml-1 inline-flex rounded px-1 py-px text-[10px] font-medium ring-1 ring-inset"
:class="isLikelyModelVariant(row)
? 'bg-amber-50 text-amber-700 ring-amber-200 dark:bg-amber-500/10 dark:text-amber-300 dark:ring-amber-500/30'
: 'bg-orange-50 text-orange-700 ring-orange-200 dark:bg-orange-500/10 dark:text-orange-300 dark:ring-orange-500/30'"
>
{{ isLikelyModelVariant(row) ? t('usage.modelVariant') : t('usage.modelMismatch') }}
</span>
</div>
</div>
<div v-else-if="row.upstream_model && row.upstream_model !== row.model" class="space-y-0.5 text-xs">
<div class="break-all font-medium text-gray-900 dark:text-white">
{{ row.model }}
</div>
<div class="break-all text-gray-500 dark:text-gray-400">
<span class="mr-0.5">↳</span>{{ row.upstream_model }}
</div>
</div>
<span v-else class="font-medium text-gray-900 dark:text-white">{{ row.model }}</span>
</template>
<template #cell-reasoning_effort="{ row }">
@@ -548,6 +566,27 @@ const ipGeoBatchLoading = ref(false)
const showIpGeoToolbar = computed(() => props.columns.some((col) => col.key === 'ip_address'))
const sentUpstreamModel = (row: AdminUsageLog): string => row.upstream_model?.trim() || row.model?.trim() || ''
const normalizeModelVariant = (model: string): string => model
.trim()
.toLowerCase()
.replace(/-latest$/, '')
.replace(/-\d{4}-\d{2}-\d{2}$/, '')
.replace(/-\d{8}$/, '')
const isLikelyModelVariant = (row: AdminUsageLog): boolean => {
const sent = sentUpstreamModel(row)
const response = row.upstream_response_model?.trim() || ''
return sent !== '' && response !== '' && normalizeModelVariant(sent) === normalizeModelVariant(response)
}
const modelAuditTitle = (row: AdminUsageLog): string => [
`${t('usage.requestedModel')}: ${row.model || '-'}`,
`${t('usage.sentUpstreamModel')}: ${sentUpstreamModel(row) || '-'}`,
`${t('usage.upstreamResponseModel')}: ${row.upstream_response_model || '-'}`,
].join('\n')
const currentPageIps = computed(() =>
Array.from(new Set(props.data.map((row) => row.ip_address).filter((ip): ip is string => Boolean(ip))))
)
@@ -28,6 +28,10 @@ const messages: Record<string, string> = {
'admin.usage.billingModeToken': 'Token',
'admin.usage.billingModePerRequest': 'Per Request',
'admin.usage.billingModeImage': 'Image',
'admin.usage.upstreamModelAudit': 'Upstream model audit',
'admin.usage.allUpstreamModelAudit': 'All response model states',
'admin.usage.upstreamModelMismatchOnly': 'Mismatched only',
'admin.usage.upstreamModelMatchedOnly': 'Matched only',
'admin.usage.group': 'Group',
'admin.usage.allGroups': 'All Groups',
'common.refresh': 'Refresh',
@@ -75,6 +79,7 @@ const defaultFilters = () => ({
request_type: null,
billing_type: null,
billing_mode: null,
upstream_model_mismatch: null,
group_id: null,
start_date: '',
end_date: '',
@@ -51,6 +51,11 @@ const messages: Record<string, string> = {
'admin.usage.billingModeToken': 'Token',
'admin.usage.billingModePerRequest': 'Per request',
'admin.usage.billingModeImage': 'Image',
'usage.requestedModel': 'Requested',
'usage.sentUpstreamModel': 'Sent upstream',
'usage.upstreamResponseModel': 'Upstream response',
'usage.modelVariant': 'Possible version variant',
'usage.modelMismatch': 'Different model',
}
vi.mock('vue-i18n', async () => {
@@ -240,6 +245,48 @@ describe('admin UsageTable tooltip', () => {
expect(text).toContain('claude-sonnet-4-20250514')
})
it.each([
{
name: 'possible version variant',
responseModel: 'gpt-5.5-2026-08-01',
expectedBadge: 'Possible version variant',
},
{
name: 'different upstream model',
responseModel: 'gpt-5.4',
expectedBadge: 'Different model',
},
])('shows a compact upstream response audit marker for $name', ({ responseModel, expectedBadge }) => {
const wrapper = mount(UsageTable, {
props: {
data: [{
request_id: `req-${responseModel}`,
model: 'gpt-5.6-sol',
upstream_model: 'gpt-5.5',
model_mapping_chain: 'gpt-5.6-sol→gpt-5.5',
upstream_response_model: responseModel,
upstream_model_mismatch: true,
}],
loading: false,
columns: [],
},
global: {
stubs: {
DataTable: DataTableStub,
EmptyState: true,
Icon: true,
Teleport: true,
},
},
})
const text = wrapper.text()
expect(text).toContain('gpt-5.6-sol')
expect(text).toContain('gpt-5.5')
expect(text).toContain(responseModel)
expect(text).toContain(expectedBadge)
})
it.each([
{
name: 'defaulted row',
@@ -59,13 +59,22 @@
<tbody>
<tr
v-for="m in sortedModels"
:key="m.name"
:key="`${m.platform}:${m.name}`"
class="border-b border-gray-100 transition-colors last:border-b-0 hover:bg-gray-50/70 dark:border-dark-800 dark:hover:bg-dark-800/50"
>
<!-- 模型名 + 非 token 计费模式徽章 -->
<td class="border-r border-gray-100 py-2.5 pl-5 pr-4 align-middle dark:border-dark-700/60">
<div class="flex flex-wrap items-center gap-1.5">
<span class="font-medium text-gray-900 dark:text-white">{{ m.name }}</span>
<span
v-if="platform && m.platform !== platform"
:class="[
'inline-flex items-center rounded-md px-1.5 py-0.5 text-[10px] font-medium',
platformBadgeLightClass(m.platform)
]"
>
{{ platformLabel(m.platform) }}
</span>
<span
v-if="billingMode(m) !== BILLING_MODE_TOKEN"
class="rounded-md bg-gray-100 px-1.5 py-0.5 text-[10px] font-medium text-gray-500 dark:bg-dark-700/70 dark:text-dark-300"
@@ -203,7 +212,7 @@
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import { formatScaled } from '@/utils/pricing'
import { platformAccentColor } from '@/utils/platformColors'
import { platformAccentColor, platformBadgeLightClass, platformLabel } from '@/utils/platformColors'
import {
BILLING_MODE_TOKEN,
BILLING_MODE_IMAGE,
@@ -383,4 +383,25 @@ describe('PlazaModelPricingTable', () => {
// 旧 bug:image_output_price × 0.1 = 0.000003 被当按次价
expect(text).not.toContain('$0.000003')
})
it('Composite 分组中相同模型名按具体平台分别展示徽章', () => {
const anthropic = tokenModel({ name: 'shared-model', platform: 'anthropic' })
const openai = tokenModel({ name: 'shared-model', platform: 'openai' })
const wrapper = mount(PlazaModelPricingTable, {
props: {
models: [anthropic, openai],
platform: 'composite',
rateMultiplier: 1
}
})
const rows = wrapper.findAll('tbody tr')
expect(rows).toHaveLength(2)
expect(rows.map((row) => row.find('td').text())).toEqual([
'shared-modelAnthropic',
'shared-modelOpenAI'
])
expect(wrapper.text()).toContain('Anthropic')
expect(wrapper.text()).toContain('OpenAI')
})
})
@@ -544,6 +544,10 @@ export default {
billingModeImage: 'Image',
billingModeVideo: 'Video',
allBillingModes: 'All Billing Modes',
upstreamModelAudit: 'Upstream model audit',
allUpstreamModelAudit: 'All response model states',
upstreamModelMismatchOnly: 'Mismatched only',
upstreamModelMatchedOnly: 'Matched only',
ipAddress: 'IP',
clickToViewBalance: 'Click to view balance history',
failedToLoadUser: 'Failed to load user info',
@@ -303,6 +303,11 @@ export default {
model: 'Model',
requestedModel: 'Requested',
upstreamModel: 'Upstream',
sentUpstreamModel: 'Sent upstream',
upstreamResponseModel: 'Upstream response',
upstreamModelMismatch: 'Response model mismatch',
modelVariant: 'Possible version variant',
modelMismatch: 'Different model',
reasoningEffort: 'Reasoning Effort',
endpoint: 'Endpoint',
endpointDistribution: 'Endpoint Distribution',
@@ -541,6 +541,10 @@ export default {
billingModeImage: '按次(图片)',
billingModeVideo: '按次(视频)',
allBillingModes: '全部计费模式',
upstreamModelAudit: '上游模型审计',
allUpstreamModelAudit: '全部响应模型状态',
upstreamModelMismatchOnly: '仅不一致',
upstreamModelMatchedOnly: '仅一致',
ipAddress: 'IP',
clickToViewBalance: '点击查看充值记录',
failedToLoadUser: '加载用户信息失败',
@@ -308,6 +308,11 @@ export default {
model: '模型',
requestedModel: '请求',
upstreamModel: '上游',
sentUpstreamModel: '发往上游',
upstreamResponseModel: '上游响应',
upstreamModelMismatch: '上游响应模型不一致',
modelVariant: '疑似版本变体',
modelMismatch: '模型不一致',
reasoningEffort: '推理强度',
endpoint: '端点',
endpointDistribution: '端点分布',
+2
View File
@@ -1680,6 +1680,8 @@ export interface UsageLogAccountSummary {
export interface AdminUsageLog extends UsageLog {
upstream_model?: string | null
upstream_response_model?: string | null
upstream_model_mismatch?: boolean | null
model_mapping_chain?: string | null
// 账号计费倍率(仅管理员可见)
+4 -2
View File
@@ -450,6 +450,7 @@ const loadModelStats = async (source: ModelDistributionSource, force = false) =>
request_type: requestType,
stream: legacyStream === null ? undefined : legacyStream,
billing_type: filters.value.billing_type,
upstream_model_mismatch: filters.value.upstream_model_mismatch,
}
const response = await adminAPI.dashboard.getModelStats({ ...baseParams, model_source: source })
@@ -499,6 +500,7 @@ const loadChartData = async () => {
request_type: requestType,
stream: legacyStream === null ? undefined : legacyStream,
billing_type: filters.value.billing_type,
upstream_model_mismatch: filters.value.upstream_model_mismatch,
include_stats: false,
include_trend: true,
include_model_stats: false,
@@ -573,7 +575,7 @@ const exportToExcel = async () => {
const XLSX = await import('xlsx')
const headers = [
t('usage.time'), t('admin.usage.user'), t('usage.apiKeyFilter'),
t('admin.usage.account'), t('usage.model'), t('usage.upstreamModel'), t('usage.reasoningEffort'), t('admin.usage.group'),
t('admin.usage.account'), t('usage.requestedModel'), t('usage.sentUpstreamModel'), t('usage.upstreamResponseModel'), t('usage.upstreamModelMismatch'), t('usage.reasoningEffort'), t('admin.usage.group'),
t('usage.inboundEndpoint'), t('usage.upstreamEndpoint'),
t('usage.type'),
t('admin.usage.inputTokens'), t('admin.usage.outputTokens'),
@@ -593,7 +595,7 @@ const exportToExcel = async () => {
if (c.signal.aborted) break; if (p === 1) { total = res.total; exportProgress.total = total }
const rows = (res.items || []).map((log: AdminUsageLog) => [
log.created_at, log.user?.email || '', log.api_key?.name || '', log.account?.name || '', log.model,
log.upstream_model || '', formatReasoningEffort(log.reasoning_effort), log.group?.name || '',
log.upstream_model || log.model, log.upstream_response_model || '', log.upstream_model_mismatch == null ? '' : t(log.upstream_model_mismatch ? 'common.yes' : 'common.no'), formatReasoningEffort(log.reasoning_effort), log.group?.name || '',
log.inbound_endpoint || '', log.upstream_endpoint || '', getRequestTypeLabel(log),
log.input_tokens, log.output_tokens, log.cache_read_tokens, log.cache_creation_tokens,
log.input_cost?.toFixed(6) || '0.000000', log.output_cost?.toFixed(6) || '0.000000',
@@ -4,7 +4,7 @@ import { defineComponent, ref } from 'vue'
import UsageView from '../UsageView.vue'
const { list, getStats, getSnapshotV2, getById, getModelStats, listErrorLogs, routeQuery } = vi.hoisted(() => {
const { list, exportList, getStats, getSnapshotV2, getById, getModelStats, listErrorLogs, routeQuery, aoaToSheet, sheetAddAoa, saveAs, xlsxWrite } = vi.hoisted(() => {
vi.stubGlobal('localStorage', {
getItem: vi.fn(() => null),
setItem: vi.fn(),
@@ -13,12 +13,17 @@ const { list, getStats, getSnapshotV2, getById, getModelStats, listErrorLogs, ro
return {
list: vi.fn(),
exportList: vi.fn(),
getStats: vi.fn(),
getSnapshotV2: vi.fn(),
getById: vi.fn(),
getModelStats: vi.fn(),
listErrorLogs: vi.fn(),
routeQuery: {} as Record<string, string>,
aoaToSheet: vi.fn(() => ({})),
sheetAddAoa: vi.fn(),
saveAs: vi.fn(),
xlsxWrite: vi.fn(() => new Uint8Array([1, 2, 3])),
}
})
@@ -27,6 +32,12 @@ const messages: Record<string, string> = {
'admin.dashboard.day': 'Day',
'admin.dashboard.hour': 'Hour',
'admin.usage.failedToLoadUser': 'Failed to load user',
'usage.requestedModel': 'Requested model',
'usage.sentUpstreamModel': 'Sent upstream model',
'usage.upstreamResponseModel': 'Upstream response model',
'usage.upstreamModelMismatch': 'Upstream model mismatch',
'common.yes': 'Yes',
'common.no': 'No',
}
const formatLocalDate = (date: Date): string => {
@@ -54,10 +65,22 @@ vi.mock('@/api/admin', () => ({
vi.mock('@/api/admin/usage', () => ({
adminUsageAPI: {
list: vi.fn(),
list: exportList,
},
}))
vi.mock('file-saver', () => ({ saveAs }))
vi.mock('xlsx', () => ({
utils: {
aoa_to_sheet: aoaToSheet,
sheet_add_aoa: sheetAddAoa,
book_new: vi.fn(() => ({})),
book_append_sheet: vi.fn(),
},
write: xlsxWrite,
}))
vi.mock('@/api/admin/ops', () => ({
listErrorLogs,
}))
@@ -539,3 +562,62 @@ describe('admin UsageView ranking tab', () => {
expect(list).toHaveBeenCalledWith(expect.objectContaining({ user_id: 5 }), expect.anything())
})
})
describe('admin UsageView model audit export', () => {
beforeEach(() => {
vi.useFakeTimers()
list.mockReset().mockResolvedValue({ items: [], total: 0, pages: 0 })
exportList.mockReset().mockResolvedValue({
items: [{
id: 1,
created_at: '2026-08-04T00:00:00Z',
model: 'gpt-5.6-sol',
upstream_model: 'gpt-5.5',
upstream_response_model: 'gpt-5.4',
upstream_model_mismatch: true,
request_type: 'sync',
input_tokens: 1,
output_tokens: 1,
cache_read_tokens: 0,
cache_creation_tokens: 0,
duration_ms: 10,
}],
total: 1,
pages: 1,
})
getStats.mockReset().mockResolvedValue({
total_requests: 0, total_input_tokens: 0, total_output_tokens: 0,
total_cache_tokens: 0, total_tokens: 0, total_cost: 0, total_actual_cost: 0, average_duration_ms: 0,
})
getSnapshotV2.mockReset().mockResolvedValue({ trend: [], models: [], groups: [] })
getModelStats.mockReset().mockResolvedValue({ models: [] })
aoaToSheet.mockClear()
sheetAddAoa.mockClear()
saveAs.mockClear()
xlsxWrite.mockClear()
})
afterEach(() => {
vi.useRealTimers()
})
it('exports requested, sent, response, and mismatch as separate admin columns', async () => {
const wrapper = mountRouteFilteredUsageView()
vi.advanceTimersByTime(120)
await flushPromises()
await (wrapper.vm as any).exportToExcel()
await flushPromises()
const headers = aoaToSheet.mock.calls[0][0][0]
expect(headers.slice(4, 8)).toEqual([
'Requested model',
'Sent upstream model',
'Upstream response model',
'Upstream model mismatch',
])
const row = sheetAddAoa.mock.calls[0][1][0]
expect(row.slice(4, 8)).toEqual(['gpt-5.6-sol', 'gpt-5.5', 'gpt-5.4', 'Yes'])
expect(saveAs).toHaveBeenCalledTimes(1)
})
})
@@ -114,6 +114,8 @@
<OpsErrorDetailsModal
:show="showErrorDetails"
:time-range="timeRange"
:custom-start-time="customStartTime"
:custom-end-time="customEndTime"
:platform="platform"
:group-id="groupId"
:error-type="errorDetailsType"
@@ -5,10 +5,13 @@ import BaseDialog from '@/components/common/BaseDialog.vue'
import Select from '@/components/common/Select.vue'
import OpsErrorLogTable from './OpsErrorLogTable.vue'
import { opsAPI, type OpsErrorLog } from '@/api/admin/ops'
import { buildOpsErrorTimeParams } from '../utils/opsErrorParams'
interface Props {
show: boolean
timeRange: string
customStartTime?: string | null
customEndTime?: string | null
platform?: string
groupId?: number | null
errorType: 'request' | 'upstream'
@@ -103,11 +106,11 @@ async function fetchErrorLogs() {
const params: Record<string, any> = {
page: page.value,
page_size: pageSize.value,
time_range: props.timeRange,
view: viewMode.value,
sort_by: sortBy.value,
sort_order: sortOrder.value
}
Object.assign(params, buildOpsErrorTimeParams(props.timeRange, props.customStartTime, props.customEndTime))
const platform = String(props.platform || '').trim()
if (platform) params.platform = platform
@@ -160,7 +163,7 @@ watch(
)
watch(
() => [props.timeRange, props.platform, props.groupId] as const,
() => [props.timeRange, props.customStartTime, props.customEndTime, props.platform, props.groupId] as const,
() => {
if (!props.show) return
page.value = 1
@@ -0,0 +1,16 @@
import { describe, expect, it } from 'vitest'
import { buildOpsErrorTimeParams } from '../opsErrorParams'
describe('buildOpsErrorTimeParams', () => {
it('uses explicit timestamps for a complete custom range', () => {
expect(buildOpsErrorTimeParams('custom', '2026-08-01T00:00:00Z', '2026-08-02T00:00:00Z')).toEqual({
start_time: '2026-08-01T00:00:00Z',
end_time: '2026-08-02T00:00:00Z'
})
})
it('preserves predefined ranges and falls back for incomplete custom ranges', () => {
expect(buildOpsErrorTimeParams('24h')).toEqual({ time_range: '24h' })
expect(buildOpsErrorTimeParams('custom', null, null)).toEqual({ time_range: '1h' })
})
})
@@ -0,0 +1,11 @@
export function buildOpsErrorTimeParams(
timeRange: string,
customStartTime?: string | null,
customEndTime?: string | null
): Record<string, string> {
if (timeRange === 'custom' && customStartTime && customEndTime) {
return { start_time: customStartTime, end_time: customEndTime }
}
return { time_range: timeRange === 'custom' ? '1h' : timeRange }
}