mirror of
https://github.com/fawney19/Aether.git
synced 2026-10-09 02:47:45 +08:00
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:
@@ -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',
|
||||
|
||||
+10
-1
@@ -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,
|
||||
|
||||
+2
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user