mirror of
https://github.com/fawney19/Aether.git
synced 2026-10-08 10:27:46 +08:00
feat(codex): align Search and execution protocol
This commit is contained in:
@@ -1052,6 +1052,7 @@ import AlertDialog from '@/components/common/AlertDialog.vue'
|
||||
import EndpointConditionEditor from './EndpointConditionEditor.vue'
|
||||
import ProxyNodeSelect from './ProxyNodeSelect.vue'
|
||||
import { getDefaultEndpointBaseUrl, getDefaultEndpointPath, normalizeEndpointApiFormat } from './endpoint-default-paths'
|
||||
import { fixedEndpointUpstreamStreamPolicy } from './endpoint-protocol-policy'
|
||||
import { useProxyNodesStore } from '@/stores/proxy-nodes'
|
||||
import {
|
||||
createEndpoint,
|
||||
@@ -3378,20 +3379,26 @@ async function handleToggleFormatConversion(endpoint: ProviderEndpoint) {
|
||||
|
||||
// 获取上游流式按钮的当前状态(优先使用编辑状态)
|
||||
function getCurrentUpstreamStreamPolicy(endpoint: ProviderEndpoint): string {
|
||||
if (isUpstreamStreamPolicyLocked(endpoint)) return 'force_stream'
|
||||
const fixedPolicy = getFixedUpstreamStreamPolicy(endpoint)
|
||||
if (fixedPolicy) return fixedPolicy
|
||||
const state = endpointEditStates.value[endpoint.id]
|
||||
return state?.upstreamStreamPolicy ?? getEndpointUpstreamStreamPolicy(endpoint)
|
||||
}
|
||||
|
||||
function getFixedUpstreamStreamPolicy(endpoint: ProviderEndpoint) {
|
||||
return fixedEndpointUpstreamStreamPolicy(props.provider?.provider_type, endpoint.api_format)
|
||||
}
|
||||
|
||||
function isUpstreamStreamPolicyLocked(endpoint: ProviderEndpoint): boolean {
|
||||
return (props.provider?.provider_type || '').toLowerCase() === 'codex'
|
||||
&& normalizeEndpointApiFormat(endpoint.api_format) === 'openai:responses'
|
||||
return getFixedUpstreamStreamPolicy(endpoint) !== null
|
||||
}
|
||||
|
||||
// 获取上游流式按钮的样式类
|
||||
function getUpstreamStreamButtonClass(endpoint: ProviderEndpoint): string {
|
||||
if (isUpstreamStreamPolicyLocked(endpoint)) {
|
||||
return 'h-7 w-7 text-primary/70 cursor-not-allowed'
|
||||
const fixedPolicy = getFixedUpstreamStreamPolicy(endpoint)
|
||||
if (fixedPolicy) {
|
||||
const color = fixedPolicy === 'force_stream' ? 'text-primary/70' : 'text-destructive/70'
|
||||
return `h-7 w-7 ${color} cursor-not-allowed`
|
||||
}
|
||||
const policy = getCurrentUpstreamStreamPolicy(endpoint)
|
||||
const base = 'h-7 w-7'
|
||||
@@ -3402,7 +3409,9 @@ function getUpstreamStreamButtonClass(endpoint: ProviderEndpoint): string {
|
||||
|
||||
// 获取上游流式按钮的提示文字
|
||||
function getUpstreamStreamTooltip(endpoint: ProviderEndpoint): string {
|
||||
if (isUpstreamStreamPolicyLocked(endpoint)) return legacyT('固定流式(Codex OpenAI Responses,已锁定)')
|
||||
const fixedPolicy = getFixedUpstreamStreamPolicy(endpoint)
|
||||
if (fixedPolicy === 'force_stream') return legacyT('Codex Responses 固定流式')
|
||||
if (fixedPolicy === 'force_non_stream') return legacyT('OpenAI Search 固定非流式')
|
||||
const policy = getCurrentUpstreamStreamPolicy(endpoint)
|
||||
if (policy === 'force_stream') return legacyT('固定流式(点击切换为固定非流)')
|
||||
if (policy === 'force_non_stream') return legacyT('固定非流(点击切换为跟随请求)')
|
||||
|
||||
@@ -631,38 +631,20 @@ async function loadBalances() {
|
||||
}
|
||||
}
|
||||
|
||||
const LEGACY_API_FORMAT_MAP: Record<string, string> = {
|
||||
CLAUDE: 'claude:messages',
|
||||
CLAUDE_MESSAGES: 'claude:messages',
|
||||
OPENAI: 'openai:chat',
|
||||
OPENAI_RESPONSES: 'openai:responses',
|
||||
OPENAI_RESPONSES_COMPACT: 'openai:responses:compact',
|
||||
OPENAI_VIDEO: 'openai:video',
|
||||
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 {
|
||||
const raw = String(value || '').trim()
|
||||
if (!raw) return ''
|
||||
const normalized = normalizeApiFormatAlias(value)
|
||||
if (!normalized) return ''
|
||||
|
||||
if (raw.includes(':')) {
|
||||
const [family, kind] = raw.split(':', 2)
|
||||
const familyNorm = family?.trim().toLowerCase()
|
||||
const kindNorm = kind?.trim().toLowerCase()
|
||||
if (familyNorm === 'claude' && ['chat', 'cli', 'messages'].includes(kindNorm)) {
|
||||
if (normalized.includes(':')) {
|
||||
const { family, kind } = parseApiFormat(normalized)
|
||||
if (family === 'claude' && ['chat', 'cli', 'messages'].includes(kind)) {
|
||||
return 'claude:messages'
|
||||
}
|
||||
if (familyNorm === 'gemini' && ['chat', 'cli', 'generate_content'].includes(kindNorm)) {
|
||||
if (family === 'gemini' && ['chat', 'cli', 'generate_content'].includes(kind)) {
|
||||
return 'gemini:generate_content'
|
||||
}
|
||||
if (familyNorm && kindNorm) return `${familyNorm}:${kindNorm}`
|
||||
}
|
||||
|
||||
const legacy = raw.toUpperCase().replace(/-/g, '_')
|
||||
return LEGACY_API_FORMAT_MAP[legacy] || raw.toLowerCase()
|
||||
return normalized
|
||||
}
|
||||
|
||||
function normalizePriorityMap(
|
||||
|
||||
@@ -197,7 +197,7 @@
|
||||
:model-value="form.request_timeout ?? ''"
|
||||
type="number"
|
||||
min="1"
|
||||
max="600"
|
||||
max="1200"
|
||||
step="1"
|
||||
placeholder="300"
|
||||
@update:model-value="(v) => form.request_timeout = parseNumberInput(v)"
|
||||
|
||||
@@ -9,6 +9,7 @@ const apiFormats = [
|
||||
{ value: 'gemini:embedding', default_path: '/v1beta/models/{model}:embedContent' },
|
||||
{ value: 'gemini:video', default_path: '/v1beta/models/{model}:predictLongRunning' },
|
||||
{ value: 'openai:responses', default_path: '/v1/responses' },
|
||||
{ value: 'openai:search', default_path: '/v1/alpha/search' },
|
||||
{ value: 'openai:embedding', default_path: '/v1/embeddings' },
|
||||
{ value: 'openai:rerank', default_path: '/v1/rerank' },
|
||||
{ value: 'openai:image', default_path: '/v1/images/generations' },
|
||||
@@ -75,6 +76,30 @@ describe('endpoint default paths', () => {
|
||||
})).toBe('/responses')
|
||||
})
|
||||
|
||||
it('uses the Search path relative to OpenAI and Codex api roots', () => {
|
||||
expect(getDefaultEndpointBaseUrl({
|
||||
apiFormat: 'openai:search',
|
||||
baseUrl: 'https://api.openai.com',
|
||||
})).toBe('https://api.openai.com/v1')
|
||||
expect(getDefaultEndpointPath({
|
||||
apiFormat: 'openai:search',
|
||||
providerType: 'openai',
|
||||
baseUrl: 'https://api.openai.com/v1',
|
||||
apiFormats,
|
||||
})).toBe('/alpha/search')
|
||||
|
||||
expect(getDefaultEndpointBaseUrl({
|
||||
apiFormat: 'openai:search',
|
||||
baseUrl: 'https://chatgpt.com/backend-api/codex',
|
||||
})).toBe('https://chatgpt.com/backend-api/codex')
|
||||
expect(getDefaultEndpointPath({
|
||||
apiFormat: 'openai:search',
|
||||
providerType: 'codex',
|
||||
baseUrl: 'https://chatgpt.com/backend-api/codex',
|
||||
apiFormats,
|
||||
})).toBe('/alpha/search')
|
||||
})
|
||||
|
||||
it('drops /v1 from API-root defaults because base URL is the API root', () => {
|
||||
expect(getDefaultEndpointPath({
|
||||
apiFormat: 'openai:chat',
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { fixedEndpointUpstreamStreamPolicy } from '../endpoint-protocol-policy'
|
||||
|
||||
describe('fixedEndpointUpstreamStreamPolicy', () => {
|
||||
it('uses the fixed Codex transport mode for each protocol', () => {
|
||||
expect(fixedEndpointUpstreamStreamPolicy('codex', 'openai:responses')).toBe('force_stream')
|
||||
expect(fixedEndpointUpstreamStreamPolicy('codex', 'openai:search')).toBe('force_non_stream')
|
||||
})
|
||||
|
||||
it('applies Search synchronization by format and keeps other formats configurable', () => {
|
||||
expect(fixedEndpointUpstreamStreamPolicy('custom', 'openai:search')).toBe('force_non_stream')
|
||||
expect(fixedEndpointUpstreamStreamPolicy('codex', 'openai:responses:compact')).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -90,6 +90,7 @@ function usesVersionedApiRootByDefault(apiFormat: string): boolean {
|
||||
return apiFormat === 'openai:chat'
|
||||
|| apiFormat === 'openai:responses'
|
||||
|| apiFormat === 'openai:responses:compact'
|
||||
|| apiFormat === 'openai:search'
|
||||
|| apiFormat === 'openai:embedding'
|
||||
|| apiFormat === 'openai:rerank'
|
||||
|| apiFormat === 'openai:image'
|
||||
@@ -129,6 +130,7 @@ function skipsVersionedApiRootDefault(apiFormat: string, baseUrl: string): boole
|
||||
return false
|
||||
}
|
||||
return isDeepSeekApiRoot(baseUrl)
|
||||
|| isCodexUrl(baseUrl)
|
||||
|| isBigModelCodingApiRoot(baseUrl)
|
||||
|| isGoogleOpenAiCompatApiRoot(baseUrl)
|
||||
|| isVertexOpenAiCompatApiRoot(baseUrl)
|
||||
@@ -198,6 +200,9 @@ export function getDefaultEndpointPath(params: {
|
||||
if (normalizedApiFormat === 'openai:responses' && isCodex) {
|
||||
return '/responses'
|
||||
}
|
||||
if (normalizedApiFormat === 'openai:search' && isCodex) {
|
||||
return '/alpha/search'
|
||||
}
|
||||
if (usesVersionedApiRootByDefault(normalizedApiFormat)) {
|
||||
return stripVersionPrefixForApiRoot(defaultPath)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
import { normalizeEndpointApiFormat } from './endpoint-default-paths'
|
||||
|
||||
export type FixedUpstreamStreamPolicy = 'force_stream' | 'force_non_stream'
|
||||
|
||||
export function fixedEndpointUpstreamStreamPolicy(
|
||||
providerType: string | null | undefined,
|
||||
apiFormat: string,
|
||||
): FixedUpstreamStreamPolicy | null {
|
||||
const normalizedApiFormat = normalizeEndpointApiFormat(apiFormat)
|
||||
if (normalizedApiFormat === 'openai:search') return 'force_non_stream'
|
||||
if (providerType?.trim().toLowerCase() !== 'codex') return null
|
||||
|
||||
switch (normalizedApiFormat) {
|
||||
case 'openai:responses':
|
||||
return 'force_stream'
|
||||
default:
|
||||
return null
|
||||
}
|
||||
}
|
||||
@@ -366,12 +366,12 @@ import { type EndpointAPIKey } from '@/api/endpoints/keys'
|
||||
import { updateModel } from '@/api/endpoints/models'
|
||||
import { parseApiError } from '@/utils/errorParser'
|
||||
import type { ProviderWithEndpointsSummary } from '@/api/endpoints'
|
||||
import { normalizeApiFormatAlias } from '@/api/endpoints/types/api-format'
|
||||
import {
|
||||
buildDefaultModelTestRequestHeaders,
|
||||
buildDefaultModelTestRequestBody,
|
||||
isModelTestableApiFormat,
|
||||
isModelTestableEndpoint,
|
||||
modelTestMappingScopeMatchesEndpoint,
|
||||
parseModelTestRequestHeadersDraft,
|
||||
parseModelTestRequestBodyDraft,
|
||||
selectPreferredModelTestEndpoint,
|
||||
@@ -852,16 +852,11 @@ function scopedMappingEndpoints(item: CombinedMapping): ProviderEndpoint[] {
|
||||
const group = item.group
|
||||
if (!group) return activeEndpoints.value
|
||||
|
||||
const apiFormats = new Set(normalizeStringList(group.apiFormats).map(normalizeApiFormatAlias))
|
||||
const endpointIds = new Set(normalizeStringList(group.endpointIds))
|
||||
const matched = activeEndpoints.value.filter((endpoint) => {
|
||||
const apiFormatMatched = apiFormats.size === 0
|
||||
|| apiFormats.has(normalizeApiFormatAlias(endpoint.api_format))
|
||||
const endpointMatched = endpointIds.size === 0 || endpointIds.has(endpoint.id)
|
||||
return apiFormatMatched && endpointMatched
|
||||
})
|
||||
|
||||
return matched.length > 0 ? matched : activeEndpoints.value
|
||||
return activeEndpoints.value.filter(endpoint => modelTestMappingScopeMatchesEndpoint(
|
||||
group.apiFormats,
|
||||
group.endpointIds,
|
||||
endpoint,
|
||||
))
|
||||
}
|
||||
|
||||
// 测试精确映射
|
||||
|
||||
+74
-1
@@ -10,6 +10,7 @@ import {
|
||||
isModelTestableEndpoint,
|
||||
isModelTestableApiFormat,
|
||||
listModelTestMappedModelOptions,
|
||||
modelTestMappingScopeMatchesEndpoint,
|
||||
normalizeModelTestMappedModelSelection,
|
||||
selectPreferredModelTestEndpoint,
|
||||
setModelTestRequestBodyModel,
|
||||
@@ -97,6 +98,20 @@ describe('buildDefaultModelTestRequestBody', () => {
|
||||
expect(body.messages).toBeUndefined()
|
||||
})
|
||||
|
||||
it('uses the synchronous OpenAI Search request contract', () => {
|
||||
const body = JSON.parse(buildDefaultModelTestRequestBody('gpt-5.6-sol', 'openai:search'))
|
||||
|
||||
expect(body).toEqual({
|
||||
id: expect.stringMatching(/^aether-model-test-/),
|
||||
model: 'gpt-5.6-sol',
|
||||
input: 'Hello! This is a test message.',
|
||||
commands: {
|
||||
search_query: [{ q: 'Hello! This is a test message.' }],
|
||||
},
|
||||
max_output_tokens: 256,
|
||||
})
|
||||
})
|
||||
|
||||
it('uses image generation tools for image models on OpenAI Responses endpoints', () => {
|
||||
const body = JSON.parse(buildDefaultModelTestRequestBody(
|
||||
'gpt-image-2',
|
||||
@@ -177,6 +192,52 @@ describe('buildDefaultModelTestRequestBody', () => {
|
||||
expect(options).toEqual([])
|
||||
})
|
||||
|
||||
it('uses one-way format permissions for Search mapping options', () => {
|
||||
const responsesOptions = listModelTestMappedModelOptions({
|
||||
provider_model_name: 'gpt-5.6-luna',
|
||||
provider_model_mappings: [{
|
||||
name: 'gpt-5.6-luna',
|
||||
priority: 1,
|
||||
api_formats: ['openai:responses'],
|
||||
}],
|
||||
}, {
|
||||
id: 'search-endpoint',
|
||||
api_format: 'openai:search',
|
||||
})
|
||||
const searchOptions = listModelTestMappedModelOptions({
|
||||
provider_model_name: 'gpt-5.6-luna',
|
||||
provider_model_mappings: [{
|
||||
name: 'gpt-5.6-luna',
|
||||
priority: 1,
|
||||
api_formats: ['openai:search'],
|
||||
}],
|
||||
}, {
|
||||
id: 'responses-endpoint',
|
||||
api_format: 'openai:responses',
|
||||
})
|
||||
|
||||
expect(responsesOptions).toEqual([{ name: 'gpt-5.6-luna', priority: 1 }])
|
||||
expect(searchOptions).toEqual([])
|
||||
})
|
||||
|
||||
it('keeps explicit mapping scopes strict when no endpoint matches', () => {
|
||||
expect(modelTestMappingScopeMatchesEndpoint(
|
||||
['openai:responses'],
|
||||
['search-endpoint'],
|
||||
{ id: 'search-endpoint', api_format: 'openai:search' },
|
||||
)).toBe(true)
|
||||
expect(modelTestMappingScopeMatchesEndpoint(
|
||||
['openai:responses'],
|
||||
['another-endpoint'],
|
||||
{ id: 'search-endpoint', api_format: 'openai:search' },
|
||||
)).toBe(false)
|
||||
expect(modelTestMappingScopeMatchesEndpoint(
|
||||
['openai:search'],
|
||||
undefined,
|
||||
{ id: 'responses-endpoint', api_format: 'openai:responses' },
|
||||
)).toBe(false)
|
||||
})
|
||||
|
||||
it('keeps the current model selected by default until a mapped model is chosen', () => {
|
||||
const options = [
|
||||
{ name: 'MiniMax-M2.7-highspeed', priority: 1 },
|
||||
@@ -259,7 +320,7 @@ describe('isModelTestableApiFormat', () => {
|
||||
'gemini:video',
|
||||
'gemini:files',
|
||||
' OPENAI:VIDEO ',
|
||||
])('excludes task and file endpoint formats from model tests: %s', (apiFormat) => {
|
||||
])('excludes endpoint formats without a model-test request contract: %s', (apiFormat) => {
|
||||
expect(isModelTestableApiFormat(apiFormat)).toBe(false)
|
||||
})
|
||||
|
||||
@@ -267,6 +328,7 @@ describe('isModelTestableApiFormat', () => {
|
||||
'openai:chat',
|
||||
'openai:responses',
|
||||
'openai:responses:compact',
|
||||
'openai:search',
|
||||
'openai:image',
|
||||
'claude:messages',
|
||||
'gemini:generate_content',
|
||||
@@ -379,6 +441,17 @@ describe('isModelTestableEndpoint', () => {
|
||||
}, keys)).toBe(true)
|
||||
})
|
||||
|
||||
it('uses one-way format permissions for Search endpoint keys', () => {
|
||||
expect(isModelTestableEndpoint({
|
||||
api_format: 'openai:search',
|
||||
is_active: true,
|
||||
}, [{ api_formats: ['openai:responses'], is_active: true }])).toBe(true)
|
||||
expect(isModelTestableEndpoint({
|
||||
api_format: 'openai:responses',
|
||||
is_active: true,
|
||||
}, [{ api_formats: ['openai:search'], is_active: true }])).toBe(false)
|
||||
})
|
||||
|
||||
it('lets fixed provider OAuth keys inherit testable endpoint formats', () => {
|
||||
const keys = [{
|
||||
api_formats: ['legacy:mismatch'],
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
import { normalizeApiFormatAlias } from '@/api/endpoints/types/api-format'
|
||||
import {
|
||||
apiFormatPermissionCovers,
|
||||
normalizeApiFormatAlias,
|
||||
} from '@/api/endpoints/types/api-format'
|
||||
import type { ModelTestCapabilities, OpenAiImageModelTestCapability } from '@/api/endpoints/types'
|
||||
|
||||
export type ModelTestEndpointSource = {
|
||||
@@ -71,7 +74,7 @@ export function modelTestKeySupportsEndpoint(
|
||||
const keyFormats = normalizeModelTestStringList(key.api_formats)
|
||||
if (keyFormats.length === 0) return true
|
||||
|
||||
return keyFormats.some(format => normalizeApiFormatAlias(format) === endpointFormat)
|
||||
return keyFormats.some(format => apiFormatPermissionCovers(format, endpointFormat))
|
||||
}
|
||||
|
||||
export function isModelTestableEndpoint(
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
import { normalizeApiFormatAlias } from '@/api/endpoints/types/api-format'
|
||||
import {
|
||||
apiFormatPermissionCovers,
|
||||
normalizeApiFormatAlias,
|
||||
} from '@/api/endpoints/types/api-format'
|
||||
import type { ProviderModelMapping } from '@/api/endpoints/types'
|
||||
import type { TestModelRequest } from '@/api/endpoints/providers'
|
||||
import {
|
||||
@@ -29,12 +32,18 @@ export type { ModelTestImagePreview } from './model-test-preview'
|
||||
|
||||
const DEFAULT_MODEL_TEST_MESSAGE = 'Hello! This is a test message.'
|
||||
|
||||
function buildModelTestSearchSessionId(): string {
|
||||
const id = globalThis.crypto?.randomUUID?.()
|
||||
?? `${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`
|
||||
return `aether-model-test-${id}`
|
||||
}
|
||||
|
||||
type ModelTestMappingSource = {
|
||||
provider_model_name: string
|
||||
provider_model_mappings?: ProviderModelMapping[] | null
|
||||
}
|
||||
|
||||
type ModelTestMappingEndpoint = {
|
||||
export type ModelTestMappingEndpoint = {
|
||||
id: string
|
||||
api_format: string
|
||||
}
|
||||
@@ -44,17 +53,19 @@ export type ModelTestMappedModelOption = {
|
||||
priority: number
|
||||
}
|
||||
|
||||
function mappingApiFormatMatches(mapping: ProviderModelMapping, endpoint: ModelTestMappingEndpoint): boolean {
|
||||
const apiFormats = normalizeModelTestStringList(mapping.api_formats)
|
||||
if (apiFormats.length === 0) return true
|
||||
export function modelTestMappingScopeMatchesEndpoint(
|
||||
apiFormats: string[] | null | undefined,
|
||||
endpointIds: string[] | null | undefined,
|
||||
endpoint: ModelTestMappingEndpoint,
|
||||
): boolean {
|
||||
const normalizedApiFormats = normalizeModelTestStringList(apiFormats)
|
||||
const endpointFormat = normalizeApiFormatAlias(endpoint.api_format)
|
||||
return apiFormats.some(format => normalizeApiFormatAlias(format) === endpointFormat)
|
||||
}
|
||||
|
||||
function mappingEndpointMatches(mapping: ProviderModelMapping, endpoint: ModelTestMappingEndpoint): boolean {
|
||||
const endpointIds = normalizeModelTestStringList(mapping.endpoint_ids)
|
||||
if (endpointIds.length === 0) return true
|
||||
return endpointIds.includes(endpoint.id)
|
||||
const apiFormatMatches = normalizedApiFormats.length === 0
|
||||
|| normalizedApiFormats.some(format => apiFormatPermissionCovers(format, endpointFormat))
|
||||
const normalizedEndpointIds = normalizeModelTestStringList(endpointIds)
|
||||
const endpointMatches = normalizedEndpointIds.length === 0
|
||||
|| normalizedEndpointIds.includes(endpoint.id)
|
||||
return apiFormatMatches && endpointMatches
|
||||
}
|
||||
|
||||
export function listModelTestMappedModelOptions(
|
||||
@@ -65,8 +76,11 @@ export function listModelTestMappedModelOptions(
|
||||
|
||||
const matchedMappings = model.provider_model_mappings
|
||||
.filter(mapping => mapping.name.trim())
|
||||
.filter(mapping => mappingApiFormatMatches(mapping, endpoint))
|
||||
.filter(mapping => mappingEndpointMatches(mapping, endpoint))
|
||||
.filter(mapping => modelTestMappingScopeMatchesEndpoint(
|
||||
mapping.api_formats,
|
||||
mapping.endpoint_ids,
|
||||
endpoint,
|
||||
))
|
||||
.sort((left, right) => {
|
||||
const leftPriority = Number.isFinite(left.priority) ? left.priority : 1
|
||||
const rightPriority = Number.isFinite(right.priority) ? right.priority : 1
|
||||
@@ -204,6 +218,18 @@ export function buildDefaultModelTestRequestBody(
|
||||
}, null, 2)
|
||||
}
|
||||
|
||||
if (normalizedApiFormat === 'openai:search') {
|
||||
return JSON.stringify({
|
||||
id: buildModelTestSearchSessionId(),
|
||||
model: modelName,
|
||||
input: DEFAULT_MODEL_TEST_MESSAGE,
|
||||
commands: {
|
||||
search_query: [{ q: DEFAULT_MODEL_TEST_MESSAGE }],
|
||||
},
|
||||
max_output_tokens: 256,
|
||||
}, null, 2)
|
||||
}
|
||||
|
||||
if (normalizedApiFormat === 'openai:responses' && modelSupportsImageGeneration(model)) {
|
||||
return JSON.stringify({
|
||||
model: modelName,
|
||||
|
||||
@@ -1,35 +1,15 @@
|
||||
import type { EndpointHealthDetail } from '@/api/endpoints'
|
||||
import { compareApiFormats } from '@/api/endpoints/types/api-format'
|
||||
import { defaultLocale, translateLegacyText, type Locale } from '@/i18n/messages'
|
||||
|
||||
// 端点状态枚举
|
||||
export type EndpointStatus = 'disabled' | 'no_keys' | 'keys_disabled' | 'available'
|
||||
|
||||
const ENDPOINT_SORT_ORDER = [
|
||||
'claude:messages',
|
||||
'openai:chat',
|
||||
'openai:responses',
|
||||
'openai:responses:compact',
|
||||
'openai:embedding',
|
||||
'openai:rerank',
|
||||
'gemini:generate_content',
|
||||
'gemini:interactions',
|
||||
'gemini:embedding',
|
||||
'openai:video',
|
||||
'gemini:video',
|
||||
'gemini:files',
|
||||
'jina:embedding',
|
||||
'jina:rerank',
|
||||
'doubao:embedding',
|
||||
'aliyun:multimodal_embedding',
|
||||
]
|
||||
|
||||
/**
|
||||
* 端点排序
|
||||
*/
|
||||
export function sortEndpoints<T extends { api_format: string }>(endpoints: T[]): T[] {
|
||||
return [...endpoints].sort((a, b) => {
|
||||
return ENDPOINT_SORT_ORDER.indexOf(a.api_format) - ENDPOINT_SORT_ORDER.indexOf(b.api_format)
|
||||
})
|
||||
return [...endpoints].sort((a, b) => compareApiFormats(a.api_format, b.api_format))
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { ref, computed, watch } from 'vue'
|
||||
import type { ProviderSummaryQuery } from '@/api/endpoints'
|
||||
import { API_FORMAT_ORDER, formatApiFormat } from '@/api/endpoints/types/api-format'
|
||||
import { useI18n } from '@/i18n'
|
||||
|
||||
export interface FilterOption {
|
||||
@@ -25,19 +26,7 @@ export function useProviderFilters(
|
||||
|
||||
const apiFormatFilters = computed<FilterOption[]>(() => [
|
||||
{ value: 'all', label: legacyT('全部格式') },
|
||||
{ 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: 'openai:embedding', label: 'OpenAI Embedding' },
|
||||
{ value: 'openai:rerank', label: 'OpenAI Rerank' },
|
||||
{ value: 'gemini:generate_content', label: 'Gemini Generate Content' },
|
||||
{ value: 'gemini:interactions', label: 'Gemini Interactions' },
|
||||
{ value: 'gemini:embedding', label: 'Gemini Embedding' },
|
||||
{ value: 'jina:embedding', label: 'Jina Embedding' },
|
||||
{ value: 'jina:rerank', label: 'Jina Rerank' },
|
||||
{ value: 'doubao:embedding', label: 'Doubao Embedding' },
|
||||
{ value: 'aliyun:multimodal_embedding', label: 'Aliyun Multimodal Embedding' },
|
||||
...API_FORMAT_ORDER.map(value => ({ value, label: formatApiFormat(value) })),
|
||||
])
|
||||
|
||||
const modelFilters = computed<FilterOption[]>(() => {
|
||||
|
||||
@@ -197,6 +197,14 @@ describe('usage status helpers', () => {
|
||||
client_is_stream: undefined,
|
||||
}))).toBe('标准->流式')
|
||||
|
||||
expect(formatUsageStreamLabel(buildUsageRecord({
|
||||
api_format: 'openai:search',
|
||||
is_stream: false,
|
||||
upstream_is_stream: false,
|
||||
client_requested_stream: undefined,
|
||||
client_is_stream: undefined,
|
||||
}))).toBe('标准')
|
||||
|
||||
expect(formatUsageStreamLabel(buildUsageRecord({
|
||||
api_format: 'claude:messages',
|
||||
is_stream: false,
|
||||
|
||||
@@ -160,6 +160,7 @@ function usageApiFormatDefaultsToNonStream(apiFormat: string): boolean {
|
||||
case 'openai:chat':
|
||||
case 'openai:responses':
|
||||
case 'openai:responses:compact':
|
||||
case 'openai:search':
|
||||
case 'openai:image':
|
||||
case 'claude:messages':
|
||||
return true
|
||||
|
||||
Reference in New Issue
Block a user