feat(gateway): add Codex Live and OpenAI Realtime

Implement preflighted Live/Realtime WebSocket transports, protocol-aware authentication, usage auditing, UI filtering, and legacy Codex permission migration.
This commit is contained in:
ZheFox
2026-08-21 04:27:34 +08:00
parent fe38dcd294
commit 2c89202001
105 changed files with 7553 additions and 947 deletions
@@ -42,13 +42,17 @@
<!-- 格式转换按钮 -->
<span
class="mr-1"
:title="isEndpointFormatConversionDisabled ? formatConversionDisabledTooltip : (endpoint.format_acceptance_config?.enabled ? '已启用格式转换(点击关闭)' : '启用格式转换')"
:title="isEndpointFormatConversionDisabled(endpoint) ? getFormatConversionDisabledTooltip(endpoint) : (endpoint.format_acceptance_config?.enabled ? '已启用格式转换(点击关闭)' : '启用格式转换')"
>
<Button
variant="ghost"
size="icon"
:class="`h-7 w-7 ${endpoint.format_acceptance_config?.enabled ? 'text-primary' : ''} ${isEndpointFormatConversionDisabled ? 'opacity-50' : ''}`"
:disabled="togglingFormatEndpointId === endpoint.id || isEndpointFormatConversionDisabled"
class="h-7 w-7"
:class="{
'text-primary': endpoint.format_acceptance_config?.enabled,
'opacity-50': isEndpointFormatConversionDisabled(endpoint),
}"
:disabled="togglingFormatEndpointId === endpoint.id || isEndpointFormatConversionDisabled(endpoint)"
@click="handleToggleFormatConversion(endpoint)"
>
<Shuffle class="w-3.5 h-3.5" />
@@ -103,6 +107,7 @@
</Popover>
<!-- 上游流式三态按钮 -->
<Button
v-if="!isWebSocketEndpointApiFormat(endpoint.api_format)"
variant="ghost"
size="icon"
:class="getUpstreamStreamButtonClass(endpoint)"
@@ -1052,7 +1057,10 @@ import AlertDialog from '@/components/common/AlertDialog.vue'
import EndpointConditionEditor from './EndpointConditionEditor.vue'
import ProxyNodeSelect from './ProxyNodeSelect.vue'
import { getDefaultEndpointBaseUrl, getDefaultEndpointPath } from './endpoint-default-paths'
import { fixedEndpointUpstreamStreamPolicy } from './endpoint-protocol-policy'
import {
fixedEndpointUpstreamStreamPolicy,
isWebSocketEndpointApiFormat,
} from './endpoint-protocol-policy'
import { useProxyNodesStore } from '@/stores/proxy-nodes'
import {
createEndpoint,
@@ -1136,13 +1144,17 @@ const emit = defineEmits<{
'endpointUpdated': []
}>()
// 计算端点级格式转换是否应该被禁用
const isEndpointFormatConversionDisabled = computed(() => {
return props.systemFormatConversionEnabled || props.providerFormatConversionEnabled
})
// WebSocket 端点只支持原始帧透传,不能进入 HTTP body 格式转换链。
function isEndpointFormatConversionDisabled(endpoint: ProviderEndpoint): boolean {
return isWebSocketEndpointApiFormat(endpoint.api_format)
|| Boolean(props.systemFormatConversionEnabled)
|| Boolean(props.providerFormatConversionEnabled)
}
// 获取禁用提示
const formatConversionDisabledTooltip = computed(() => {
function getFormatConversionDisabledTooltip(endpoint: ProviderEndpoint): string {
if (isWebSocketEndpointApiFormat(endpoint.api_format)) {
return legacyT('WebSocket 端点不支持格式转换')
}
if (props.systemFormatConversionEnabled) {
return legacyT('请先关闭系统级开关')
}
@@ -1150,7 +1162,7 @@ const formatConversionDisabledTooltip = computed(() => {
return legacyT('请先关闭提供商级开关')
}
return ''
})
}
const { success, error: showError } = useToast()
const proxyNodesStore = useProxyNodesStore()
@@ -1046,6 +1046,7 @@ import {
} from '@/utils/providerKeyStatus'
import { getGeminiCliAccountCreditsText } from '@/utils/providerKeyQuota'
import {
clearPendingCodexResetCreditIdempotencyKey,
clearPendingCodexResetCreditIdempotencyKeyForOutcome,
createCodexResetCreditIdempotencyKey,
formatCodexResetCreditCount as formatCodexResetCreditCountLabel,
@@ -303,13 +303,6 @@ interface ReviewImportItem {
settings: ImportSettings
}
const REVIEW_PAGE_SIZE = 50
const steps: ReadonlyArray<{ id: WizardStep; label: string }> = [
{ id: 1, label: '导入内容' },
{ id: 2, label: '统一配置' },
{ id: 3, label: '逐项确认' },
]
const props = defineProps<{
open: boolean
providerId: string
@@ -322,6 +315,13 @@ const emit = defineEmits<{
saved: []
}>()
const REVIEW_PAGE_SIZE = 50
const steps: ReadonlyArray<{ id: WizardStep; label: string }> = [
{ id: 1, label: '导入内容' },
{ id: 2, label: '统一配置' },
{ id: 3, label: '逐项确认' },
]
const { success, warning, error: showError } = useToast()
const currentStep = ref<WizardStep>(1)
const inputText = ref('')
@@ -9,11 +9,13 @@ 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:realtime', default_path: '/v1/realtime' },
{ 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' },
{ value: 'openai:video', default_path: '/v1/videos' },
{ value: 'codex:live', default_path: '/v1/live' },
{ value: 'jina:embedding', default_path: '/v1/embeddings' },
{ value: 'jina:rerank', default_path: '/v1/rerank' },
{ value: 'claude:messages', default_path: '/v1/messages' },
@@ -76,6 +78,41 @@ describe('endpoint default paths', () => {
})).toBe('/responses')
})
it('uses separate Realtime and Codex Live endpoint defaults', () => {
expect(getDefaultEndpointBaseUrl({
apiFormat: 'openai:realtime',
baseUrl: 'https://api.openai.com',
})).toBe('https://api.openai.com/v1')
expect(getDefaultEndpointPath({
apiFormat: 'openai:realtime',
providerType: 'openai',
baseUrl: 'https://api.openai.com/v1',
apiFormats,
})).toBe('/realtime')
expect(getDefaultEndpointBaseUrl({
apiFormat: 'codex:live',
baseUrl: 'https://chatgpt.com/backend-api/codex',
})).toBe('https://chatgpt.com/backend-api/codex')
expect(getDefaultEndpointPath({
apiFormat: 'codex:live',
providerType: 'codex',
baseUrl: 'https://chatgpt.com/backend-api/codex',
apiFormats,
})).toBe('/live')
expect(getDefaultEndpointBaseUrl({
apiFormat: 'codex:live',
baseUrl: 'https://proxy.example.com',
})).toBe('https://proxy.example.com/v1')
expect(getDefaultEndpointPath({
apiFormat: 'codex:live',
providerType: 'custom',
baseUrl: 'https://proxy.example.com/v1',
apiFormats,
})).toBe('/live')
})
it('uses the Search path relative to OpenAI and Codex api roots', () => {
expect(getDefaultEndpointBaseUrl({
apiFormat: 'openai:search',
@@ -1,6 +1,9 @@
import { describe, expect, it } from 'vitest'
import { fixedEndpointUpstreamStreamPolicy } from '../endpoint-protocol-policy'
import {
fixedEndpointUpstreamStreamPolicy,
isWebSocketEndpointApiFormat,
} from '../endpoint-protocol-policy'
describe('fixedEndpointUpstreamStreamPolicy', () => {
it('uses the fixed Codex transport mode for each protocol', () => {
@@ -12,4 +15,10 @@ describe('fixedEndpointUpstreamStreamPolicy', () => {
expect(fixedEndpointUpstreamStreamPolicy('custom', 'openai:search')).toBe('force_non_stream')
expect(fixedEndpointUpstreamStreamPolicy('codex', 'openai:responses:compact')).toBeNull()
})
it('identifies formats whose transport is WebSocket rather than HTTP streaming', () => {
expect(isWebSocketEndpointApiFormat('openai:realtime')).toBe(true)
expect(isWebSocketEndpointApiFormat(' CODEX:LIVE ')).toBe(true)
expect(isWebSocketEndpointApiFormat('openai:responses')).toBe(false)
})
})
@@ -90,11 +90,13 @@ function usesVersionedApiRootByDefault(apiFormat: string): boolean {
return apiFormat === 'openai:chat'
|| apiFormat === 'openai:responses'
|| apiFormat === 'openai:responses:compact'
|| apiFormat === 'openai:realtime'
|| apiFormat === 'openai:search'
|| apiFormat === 'openai:embedding'
|| apiFormat === 'openai:rerank'
|| apiFormat === 'openai:image'
|| apiFormat === 'openai:video'
|| apiFormat === 'codex:live'
|| apiFormat === 'jina:embedding'
|| apiFormat === 'jina:rerank'
|| apiFormat === 'claude:messages'
@@ -2,6 +2,11 @@ import { normalizeEndpointApiFormat } from './endpoint-default-paths'
export type FixedUpstreamStreamPolicy = 'force_stream' | 'force_non_stream'
export function isWebSocketEndpointApiFormat(apiFormat: string): boolean {
const normalizedApiFormat = normalizeEndpointApiFormat(apiFormat)
return normalizedApiFormat === 'openai:realtime' || normalizedApiFormat === 'codex:live'
}
export function fixedEndpointUpstreamStreamPolicy(
providerType: string | null | undefined,
apiFormat: string,
@@ -317,6 +317,8 @@ describe('buildExactModelMappingTestRequest', () => {
describe('isModelTestableApiFormat', () => {
it.each([
'openai:realtime',
'codex:live',
'openai:video',
'gemini:video',
'gemini:files',
@@ -24,6 +24,8 @@ export type ModelTestKeySource = {
}
const MODEL_TEST_UNSUPPORTED_API_FORMATS = new Set([
'openai:realtime',
'codex:live',
'openai:video',
'gemini:video',
'gemini:files',