feat(codex): align Search and execution protocol

This commit is contained in:
MMEXA
2026-07-12 03:04:15 +08:00
parent 59d37ae1dd
commit 2316df5c9a
121 changed files with 5141 additions and 543 deletions
@@ -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,
))
}
// 测试精确映射
@@ -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