Normalize canonical API formats

This commit is contained in:
fawney19
2026-04-29 09:25:19 +08:00
parent 02ad67fe33
commit 07a319259b
171 changed files with 2460 additions and 1962 deletions

View File

@@ -688,18 +688,12 @@ interface ApiFormatGroup {
const STANDARD_ROUTING_API_FORMATS = [
'openai:chat',
'openai:responses',
'claude:chat',
'claude:cli',
'gemini:chat',
'gemini:cli'
'claude:messages',
'gemini:generate_content'
]
function normalizeLegacyOpenAIFormatAlias(apiFormat: string): string {
switch (apiFormat.trim().toLowerCase()) {
case 'openai:cli':
return 'openai:responses'
case 'openai:compact':
return 'openai:responses:compact'
default:
return apiFormat.trim().toLowerCase()
}
@@ -707,11 +701,9 @@ function normalizeLegacyOpenAIFormatAlias(apiFormat: string): string {
function apiDataFormatId(apiFormat: string): string | null {
switch (normalizeLegacyOpenAIFormatAlias(apiFormat)) {
case 'claude:chat':
case 'claude:cli':
case 'claude:messages':
return 'claude'
case 'gemini:chat':
case 'gemini:cli':
case 'gemini:generate_content':
return 'gemini'
case 'openai:chat':
return 'openai_chat'
@@ -736,11 +728,9 @@ function requestConversionKind(clientApiFormat: string, providerApiFormat: strin
return 'to_openai_chat'
case 'openai:responses':
return 'to_openai_responses'
case 'claude:chat':
case 'claude:cli':
case 'claude:messages':
return 'to_claude'
case 'gemini:chat':
case 'gemini:cli':
case 'gemini:generate_content':
return 'to_gemini'
default:
return null

View File

@@ -264,7 +264,7 @@ async function handleTestModel(modelName: string) {
provider_id: props.providerId,
model_name: modelName,
api_key_id: props.keyId,
api_format: 'gemini:chat',
api_format: 'gemini:generate_content',
})
if (result.success) {

View File

@@ -1338,10 +1338,6 @@ function hasDefaultBodyRules(apiFormat: string): boolean {
function normalizeLegacyOpenAIFormatAlias(apiFormat: string): string {
switch (apiFormat.trim().toLowerCase()) {
case 'openai:cli':
return 'openai:responses'
case 'openai:compact':
return 'openai:responses:compact'
default:
return apiFormat.trim().toLowerCase()
}
@@ -1385,10 +1381,10 @@ function getDefaultPath(apiFormat: string, baseUrl?: string): string {
const providerType = (props.provider?.provider_type || '').toLowerCase()
const normalizedApiFormat = normalizeLegacyOpenAIFormatAlias(apiFormat)
if (providerType === 'vertex_ai') {
if (normalizedApiFormat === 'gemini:chat') {
if (normalizedApiFormat === 'gemini:generate_content') {
return '/v1/publishers/google/models/{model}:{action}'
}
if (normalizedApiFormat === 'claude:chat') {
if (normalizedApiFormat === 'claude:messages') {
return '/v1/projects/{project_id}/locations/{region}/publishers/anthropic/models/{model}:{action}'
}
}

View File

@@ -350,7 +350,7 @@ import {
type CapabilityDefinition,
type ProviderType
} from '@/api/endpoints'
import { formatApiFormat } from '@/api/endpoints/types/api-format'
import { formatApiFormat, normalizeApiFormatAlias } from '@/api/endpoints/types/api-format'
const props = defineProps<{
open: boolean
@@ -370,13 +370,13 @@ const { success, error: showError } = useToast()
function getVertexAllowedFormatsByAuth(authType: 'api_key' | 'service_account'): Set<string> {
if (authType === 'api_key') {
return new Set(['gemini:chat'])
return new Set(['gemini:generate_content'])
}
return new Set(['gemini:chat', 'claude:chat'])
return new Set(['gemini:generate_content', 'claude:messages'])
}
function normalizeApiFormat(format: string): string {
return String(format || '').trim().toLowerCase()
return normalizeApiFormatAlias(format).trim().toLowerCase()
}
function getAvailableApiFormatSet(): Set<string> {

View File

@@ -529,7 +529,7 @@ function handleDialogUpdate(value: boolean) {
// 主 Tab 状态
const activeMainTab = ref<'provider' | 'key'>('provider')
const activeFormatTab = ref<string>('claude:chat')
const activeFormatTab = ref<string>('claude:messages')
// 提供商排序状态
const sortedProviders = ref<ProviderWithEndpointsSummary[]>([])
@@ -614,17 +614,16 @@ async function loadBalances() {
}
const LEGACY_API_FORMAT_MAP: Record<string, string> = {
CLAUDE: 'claude:chat',
CLAUDE_CLI: 'claude:cli',
CLAUDE: 'claude:messages',
CLAUDE_MESSAGES: 'claude:messages',
OPENAI: 'openai:chat',
OPENAI_CLI: 'openai:responses',
OPENAI_COMPACT: 'openai:responses:compact',
OPENAI_RESPONSES: 'openai:responses',
OPENAI_RESPONSES_COMPACT: 'openai:responses:compact',
OPENAI_VIDEO: 'openai:video',
GEMINI: 'gemini:chat',
GEMINI_CLI: 'gemini:cli',
GEMINI: 'gemini:generate_content',
GEMINI_GENERATE_CONTENT: 'gemini:generate_content',
GEMINI_VIDEO: 'gemini:video',
GEMINI_FILES: 'gemini:files',
}
function normalizeApiFormatKey(value: string | null | undefined): string {
@@ -635,6 +634,12 @@ function normalizeApiFormatKey(value: string | null | undefined): string {
const [family, kind] = raw.split(':', 2)
const familyNorm = family?.trim().toLowerCase()
const kindNorm = kind?.trim().toLowerCase()
if (familyNorm === 'claude' && ['chat', 'cli', 'messages'].includes(kindNorm)) {
return 'claude:messages'
}
if (familyNorm === 'gemini' && ['chat', 'cli', 'generate_content'].includes(kindNorm)) {
return 'gemini:generate_content'
}
if (familyNorm && kindNorm) return `${familyNorm}:${kindNorm}`
}

View File

@@ -4,15 +4,14 @@ import type { EndpointHealthDetail } from '@/api/endpoints'
export type EndpointStatus = 'disabled' | 'no_keys' | 'keys_disabled' | 'available'
const ENDPOINT_SORT_ORDER = [
'claude:chat',
'claude:cli',
'claude:messages',
'openai:chat',
'openai:responses',
'openai:responses:compact',
'gemini:chat',
'gemini:cli',
'gemini:generate_content',
'openai:video',
'gemini:video',
'gemini:files',
]
/**

View File

@@ -23,13 +23,11 @@ export function useProviderFilters(
const apiFormatFilters: FilterOption[] = [
{ value: 'all', label: '全部格式' },
{ value: 'claude:chat', label: 'Claude Chat' },
{ value: 'claude:cli', label: 'Claude CLI' },
{ value: 'claude:messages', label: 'Claude Messages' },
{ value: 'openai:chat', label: 'OpenAI Chat' },
{ value: 'openai:responses', label: 'OpenAI Responses' },
{ value: 'openai:responses:compact', label: 'OpenAI Responses Compact' },
{ value: 'gemini:chat', label: 'Gemini Chat' },
{ value: 'gemini:cli', label: 'Gemini CLI' },
{ value: 'gemini:generate_content', label: 'Gemini Generate Content' },
]
const modelFilters = computed<FilterOption[]>(() => {

View File

@@ -25,7 +25,7 @@ describe('usage token normalization', () => {
cache_creation_input_tokens: 1530,
cache_read_input_tokens: 104026,
output_tokens: 591,
api_format: 'claude:chat',
api_format: 'claude:messages',
}
expect(getEffectiveInputTokens(usage)).toBe(1)
@@ -47,7 +47,7 @@ describe('usage token normalization', () => {
cache_creation_input_tokens: 687,
cache_read_input_tokens: 52873,
output_tokens: 973,
api_format: 'claude:chat',
api_format: 'claude:messages',
})).toBe(4941)
})
})

View File

@@ -757,11 +757,10 @@ const AVAILABLE_API_FORMATS = [
{ value: 'openai:responses', label: 'OpenAI Responses' },
{ value: 'openai:responses:compact', label: 'OpenAI Responses Compact' },
{ value: 'openai:video', label: 'OpenAI Video' },
{ value: 'claude:chat', label: 'Claude Chat' },
{ value: 'claude:cli', label: 'Claude CLI' },
{ value: 'gemini:chat', label: 'Gemini Chat' },
{ value: 'gemini:cli', label: 'Gemini CLI' },
{ value: 'claude:messages', label: 'Claude Messages' },
{ value: 'gemini:generate_content', label: 'Gemini Generate Content' },
{ value: 'gemini:video', label: 'Gemini Video' },
{ value: 'gemini:files', label: 'Gemini Files' },
] as const
// 使用模块级常量
@@ -844,7 +843,7 @@ function formatOptionalTokens(value: number | null | undefined): string {
// useDebounceFn 自动处理清理,无需 onUnmounted
// 判断是否应该显示格式转换信息
// 包括1. 跨格式转换has_format_conversion=true2. 同族格式差异(如 CLAUDE_CLI → CLAUDE
// 包括1. 跨格式转换has_format_conversion=true2. 同族格式差异
function shouldShowFormatConversion(record: UsageRecord): boolean {
if (!record.api_format || !record.endpoint_api_format) {
return false

View File

@@ -20,7 +20,7 @@ describe('Gemini conversation parser', () => {
}
it('parses normalized output payload when hint is gemini', () => {
const parsed = parseResponse(normalizedResponse, requestBody, 'gemini:chat')
const parsed = parseResponse(normalizedResponse, requestBody, 'gemini:generate_content')
expect(parsed.apiFormat).toBe('gemini')
expect(parsed.messages).toHaveLength(1)
expect(parsed.messages[0]?.role).toBe('assistant')
@@ -31,7 +31,7 @@ describe('Gemini conversation parser', () => {
})
it('renders normalized output payload when hint is gemini', () => {
const rendered = renderResponse(normalizedResponse, requestBody, 'gemini:chat')
const rendered = renderResponse(normalizedResponse, requestBody, 'gemini:generate_content')
expect(rendered.error).toBeUndefined()
expect(rendered.blocks).toHaveLength(1)
expect(rendered.blocks[0]).toMatchObject({

View File

@@ -135,7 +135,7 @@ describe('usage status helpers', () => {
it('defaults OpenAI and Claude requests to non-stream when client flags are absent', () => {
expect(formatUsageStreamLabel(buildUsageRecord({
api_format: 'openai:cli',
api_format: 'openai:responses',
is_stream: true,
upstream_is_stream: true,
client_requested_stream: undefined,
@@ -143,7 +143,7 @@ describe('usage status helpers', () => {
}))).toBe('标准->流式')
expect(formatUsageStreamLabel(buildUsageRecord({
api_format: 'claude:chat',
api_format: 'claude:messages',
is_stream: false,
upstream_is_stream: false,
client_requested_stream: undefined,
@@ -153,7 +153,7 @@ describe('usage status helpers', () => {
it('keeps upstream fallback for formats without a default non-stream convention', () => {
expect(formatUsageStreamLabel(buildUsageRecord({
api_format: 'gemini:cli',
api_format: 'gemini:generate_content',
is_stream: true,
upstream_is_stream: true,
client_requested_stream: undefined,
@@ -163,7 +163,7 @@ describe('usage status helpers', () => {
it('prefers client_requested_stream over stale client_is_stream when they disagree', () => {
expect(formatUsageStreamLabel(buildUsageRecord({
api_format: 'openai:cli',
api_format: 'openai:responses',
is_stream: true,
upstream_is_stream: true,
client_requested_stream: false,

View File

@@ -124,14 +124,10 @@ function normalizeUsageStreamApiFormat(value: string | null | undefined): string
switch (normalized) {
case 'openai':
return 'openai:chat'
case 'openai:cli':
return 'openai:responses'
case 'openai:compact':
return 'openai:responses:compact'
case 'claude':
return 'claude:chat'
return 'claude:messages'
case 'gemini':
return 'gemini:chat'
return 'gemini:generate_content'
default:
return normalized
}
@@ -143,8 +139,7 @@ function usageApiFormatDefaultsToNonStream(apiFormat: string): boolean {
case 'openai:responses':
case 'openai:responses:compact':
case 'openai:image':
case 'claude:chat':
case 'claude:cli':
case 'claude:messages':
return true
default:
return false